UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

140 lines • 7.82 kB
/** * 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 { softmax } from './softmax.js'; /** Pairwise (top-2 softmax) gap below which tier 2 is a NEAR-TIE. */ export const NEAR_TIE_MARGIN = 0.15; /** How many near-tied candidates the tier-3 menu offers (plus STAY). */ export const MENU_SIZE = 3; /** The defaults, as one value — what an undeclared policy resolves to. */ export const DEFAULT_ROUTING_POLICY = { nearTieMargin: NEAR_TIE_MARGIN, menuSize: MENU_SIZE, }; /** Fill a partial override against the defaults. Pure. */ export function resolveRoutingPolicy(policy) { return { nearTieMargin: policy?.nearTieMargin ?? NEAR_TIE_MARGIN, menuSize: policy?.menuSize ?? MENU_SIZE, ...(policy?.floor !== undefined && { floor: policy.floor }), }; } /** Pairwise top-2 softmax gap: share(a) − share(b) over just [a, b] = tanh((a−b)/2). */ function pairwiseGap(a, b) { return Math.tanh((a - b) / 2); } /** * 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 function decideTier2(scores, incumbentId, policy, scorer = {}) { const safe = scores.map((s) => (Number.isFinite(s.score) ? s.score : Number.NEGATIVE_INFINITY)); const relevances = softmax(safe); const ranked = scores .map((s, i) => ({ id: s.id, score: s.score, relevance: relevances[i], safe: safe[i] })) .sort((a, b) => b.safe - a.safe) .map(({ id, score, relevance }) => ({ id, score, relevance })); const safeOf = new Map(scores.map((s, i) => [s.id, safe[i]])); const floor = policy.floor ?? scorer.floor; // A non-finite raw score is sanitized to -Infinity and can never be decisive: // it is filtered here even with no floor declared, so a lone NaN/Infinity // candidate reads as "unmatched", never as an uncontested winner. const alive = ranked.filter((r) => safeOf.get(r.id) !== Number.NEGATIVE_INFINITY && (floor === undefined || safeOf.get(r.id) > floor)); if (alive.length === 0) { // Nothing above the floor (or nothing at all): honestly unmatched. return { kind: 'unmatched', ranked, decisive: false }; } const top = alive[0]; const second = alive[1]; const gap = second === undefined ? 1 : pairwiseGap(safeOf.get(top.id), safeOf.get(second.id)); const runnerUp = second !== undefined ? { id: second.id, gap } : undefined; const decisive = scorer.categorical === true || second === undefined || gap >= policy.nearTieMargin; const numbers = { ranked, ...(runnerUp && { runnerUp }), decisive }; if (decisive) { if (incumbentId !== undefined && top.id === incumbentId) return { kind: 'stay', ...numbers }; return { kind: 'move', to: top.id, ...numbers }; } // Near-tie. Mid-conversation the incumbent holds (ambiguity = stay); // cold start opens the menu of the tied cluster. ONE exception: the scorer // scored the incumbent ITSELF and put it out (at/below the floor, or // non-finite) — then the ambiguity is between new candidates only, and // staying would cling to a skill the scorer says this message does not // match, so the tied cluster is offered instead. An incumbent the scorer // never scored is UNKNOWN, not out: it still holds. With no floor declared // (the embedding default) every finite score is alive, so a contentless // follow-up ("and yesterday?") still stays exactly as before. const incumbentScoredOut = incumbentId !== undefined && safeOf.has(incumbentId) && !alive.some((r) => r.id === incumbentId); if (incumbentId !== undefined && !incumbentScoredOut) return { kind: 'stay', ...numbers }; const tied = alive .filter((r) => pairwiseGap(safeOf.get(top.id), safeOf.get(r.id)) < policy.nearTieMargin) .slice(0, Math.max(2, policy.menuSize)) .map((r) => r.id); return { kind: 'menu', offered: tied, ...numbers }; } /** * 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 function menuOutstanding(turnRoute, currentSkillId) { if (turnRoute?.offered === undefined) return false; return currentSkillId === undefined || currentSkillId === turnRoute.from; } //# sourceMappingURL=routingPolicy.js.map