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
JavaScript
/**
* 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