/** * Relevance-based history-turn selection — the companion to {@link subtractToFit} that decides WHICH * prior turns are worth replaying at all, before the subtractive pass ever runs. * * @module @nhtio/adk/batteries/context/thrift/relevance * * @remarks * {@link @nhtio/adk/batteries/context/thrift/subtractive_pass!subtractToFit} sheds a working set's * OLDEST turns first once the budget is blown — a purely recency-based (FIFO) policy. This module is * a smarter alternative for the turn-selection step upstream of that: instead of "the last N turns," * walk the ENTIRE history (no turn-count cap) and keep any turn whose content is LEXICALLY RELEVANT to * the current query, regardless of how old it is, while always keeping the most recent `keepRecent` * turns verbatim (coreference — "it", "that file" — needs the immediately preceding turns present no * matter what). * * This is a genuine head-to-head-evaluated alternative to naive recency, not a hypothetical: in the * flagship reference agent this battery was extracted from, {@link selectRelevantTurns} was the * TREATMENT arm and {@link selectNaiveTurns} was the BASELINE arm of the same evaluation the battery * barrel documents. Use `selectRelevantTurns` when you want the evaluated win; `selectNaiveTurns` * remains exported as an honest, drop-in comparison baseline (or for a caller who simply wants FIFO). * * Zero imports, same as every module in this battery — token measurement is via an injected * {@link EstimateTokensFn}, never a bundled tokenizer. */ import type { EstimateTokensFn } from "./contracts"; export type { EstimateTokensFn }; /** * The relevance floor's lower bound — the minimum fraction of the current query's content words a * turn must share to be kept, used when the window is nearly empty (permissive: keep almost anything * that shares ANY real overlap). * * @remarks * CALIBRATED against a relevance oracle over a 94-turn stress corpus, triple-confirmed across three * model families acting as independent judges (gpt-5.5, gemini-3.5-flash, claude-haiku-4.5): the * F1-optimal floor (best balance of precision and recall when window budget is abundant) landed at * `0.125` for gpt-5.5 and claude-haiku-4.5 exactly, and `0.111` for gemini-3.5-flash — `0.125` was * chosen as the shared default (a hair stricter than gemini's number, i.e. slightly more permissive * than not, which the calibration run characterized as "slightly permissive" rather than * under-inclusive — the safer direction to err when the window has room to spare). */ export declare const RELEVANCE_FLOOR_MIN = 0.125; /** * The relevance floor's upper bound — the minimum shared-content-word fraction required when the * window is nearly full (strict: only keep turns with strong, unambiguous overlap). * * @remarks * CALIBRATED against the same triple-oracle stress corpus as {@link RELEVANCE_FLOOR_MIN}: the * high-precision floor (precision ≥ 0.9, i.e. "when in doubt about whether this survives, don't miss * on the side of dropping something the user is about to ask about") landed BIT-IDENTICAL at * `0.4286` across all three judge models (gpt-5.5, gemini-3.5-flash, claude-haiku-4.5) — a striking * cross-model agreement that grounds this as a real property of the corpus, not judge idiosyncrasy. */ export declare const RELEVANCE_FLOOR_MAX = 0.43; /** * The exponent shaping how the relevance floor scales between {@link RELEVANCE_FLOOR_MIN} and * {@link RELEVANCE_FLOOR_MAX} as window utilization rises from 0 to 1 (see * {@link scaledRelevanceFloor}). * * @remarks * `2` — a CONVEX curve (utilization raised to this power) — was chosen deliberately over a linear * scale: it stays permissive across most of the window's life (older turns keep surviving on weak * overlap while there's room to spare) and only tightens sharply once the window is GENUINELY filling * up, rather than progressively squeezing out marginal turns from the very first token of pressure. * The convex shape is itself part of what the calibration run validated, not an arbitrary choice. */ export declare const RELEVANCE_FLOOR_CURVE = 2; /** * Scale the relevance floor between {@link RELEVANCE_FLOOR_MIN} (empty window, permissive) and * {@link RELEVANCE_FLOOR_MAX} (full window, strict) by a convex curve on window utilization — see * {@link RELEVANCE_FLOOR_CURVE} for why convex. * * @param utilization - How full the window already is, as a fraction in `[0, 1]` (values outside the * range are clamped). Typically `olderTurnsTokens / historyBudget` — how much of the OLDER-turn * budget is already spent by turns kept unconditionally (e.g. the `keepRecent` window). * @returns The minimum shared-content-word fraction (see {@link relevanceToQuery}) a turn must clear * to survive selection at this utilization level. */ export declare function scaledRelevanceFloor(utilization: number): number; /** * Extract the "content word" tokens of a string for lexical overlap scoring: lowercased alphanumeric * runs of length >= 4, deduplicated into a set. Deliberately crude (no stemming, no stopword list * beyond the length-4 floor) — this is a cheap, zero-model-call, zero-dependency relevance signal, not * a semantic one; the calibration in {@link RELEVANCE_FLOOR_MIN}/{@link RELEVANCE_FLOOR_MAX} was run * against exactly this scoring function, so changing it invalidates those constants. * * @param text - Text to extract content-word tokens from. * @returns The distinct content words found, lowercased. */ export declare function contentTokens(text: string): Set; /** * Render a tool call's arguments into a short text fragment for inclusion in a turn's relevance text * (see {@link groupHistoryIntoTurns}) — best-effort `JSON.stringify`, truncated, empty on failure * (e.g. circular structures) or absent args. * * @param args - The tool call's arguments, in whatever shape the caller's tool-call record carries. * @returns A short (<=200 char) text fragment, or an empty string if `args` is nullish or * unserializable. */ export declare function argText(args: unknown): string; /** * What fraction of the QUERY's content words appear in `text` — the relevance of a prior turn to the * CURRENT query (not the reverse: a turn need not share all its own content, only enough of the * query's). * * @param text - The candidate turn's combined text (see `HistoryTurn.qa`). * @param queryTokens - The current query's content-word set, from {@link contentTokens}. * @returns A fraction in `[0, 1]`; `0` when `queryTokens` is empty (an empty query matches nothing). */ export declare function relevanceToQuery(text: string, queryTokens: ReadonlySet): number; /** * The minimal structural shape of a conversation message this module groups into turns. `createdAt` * is a lexicographically-sortable timestamp string (e.g. ISO-8601) — turns and tool calls are ordered * by string comparison (`localeCompare`), not parsed into a `Date`, so any consistently-formatted * sortable string works. * * @remarks * This is intentionally a DIFFERENT (simpler) shape than * {@link @nhtio/adk/batteries/context/thrift/contracts!WorkingMessage} — turn grouping runs UPSTREAM * of the subtractive pass, over raw conversation history, before it becomes working-set items. A * caller's real message type will usually carry more fields than this; because every function here * accepts a generic `M extends RelevanceMessage`, extra fields pass through untouched. */ export interface RelevanceMessage { /** `'user'`, `'assistant'`, or any other role identifier — an `'assistant'` message closes the * current turn (see {@link groupHistoryIntoTurns}). */ role: string; /** The message's rendered text, folded into the turn's combined relevance text. */ content: string; /** Sortable creation timestamp (e.g. ISO-8601). */ createdAt: string; } /** * The minimal structural shape of a tool call this module attaches to the turn it occurred in. */ export interface RelevanceToolCall { /** Sortable creation timestamp (e.g. ISO-8601), compared against message timestamps to find which * turn a call belongs to. */ createdAt: string; /** The tool's name, folded into the turn's combined relevance text when present. Omit for a call * with no resolved tool name yet. */ tool?: string; /** The call's arguments, rendered via {@link argText} into the turn's combined relevance text. */ args?: unknown; } /** * One grouped conversation turn: the messages from a user message through the next assistant message * (inclusive), plus any tool calls that occurred during it, and `qa` — the turn's full combined text * (every message's content plus every tool call's `name args` fragment) used for relevance scoring. */ export interface HistoryTurn { /** The turn's combined text (all message content + `tool argText(args)` fragments), the string * {@link relevanceToQuery} scores against. */ qa: string; /** The turn's timestamp, updated to the latest message folded into it — used for * `selectNaiveTurns`' recency ordering. */ createdAt: string; /** The messages belonging to this turn, in order. */ messages: M[]; /** The tool calls attributed to this turn (by timestamp — see {@link groupHistoryIntoTurns}). */ toolCalls: TC[]; } /** * Group a flat message history (plus its tool calls) into {@link HistoryTurn}s: a new turn starts at * each message that begins a fresh exchange and closes at the next `'assistant'`-role message * (inclusive); every message up to the first turn boundary that has no preceding messages also starts * a turn. Tool calls are attributed to the LATEST turn whose first message precedes the call's * timestamp. * * @param messages - The full flat message history, in chronological order. * @param toolCalls - The full flat tool-call history, in any order (this function sorts by * attribution, not the input order). * @returns The grouped turns, in chronological order. */ export declare function groupHistoryIntoTurns(messages: readonly M[], toolCalls: readonly TC[]): Array>; /** * Options for {@link selectRelevantTurns}. */ export interface SelectRelevantTurnsOptions { /** * REQUIRED. Measures a turn's combined `qa` text's token cost. There is no default — this battery * ships with no bundled tokenizer. */ estimateTokens: EstimateTokensFn; /** The encoding to measure under. Default: `'cl100k_base'` (see * {@link @nhtio/adk/batteries/context/thrift/subtractive_pass!DEFAULT_ENCODING} for the rationale — * this module makes the identical choice for the identical reason). */ encoding?: string; /** * How many of the MOST RECENT turns to always keep regardless of relevance score — coreference * ("it", "that file", "the one you just showed me") needs the immediately preceding turns present no * matter what a lexical-overlap score says about them. Default: `2`, the flagship reference agent's * own calibrated value (`KEEP_RECENT_TURNS`). */ keepRecent?: number; /** * The token budget available for OLDER (non-`keepRecent`) history — used only to compute window * UTILIZATION for {@link scaledRelevanceFloor} (how full is the older-history slice already, before * any turns are dropped), not as a hard cap this function enforces itself (that is * `subtractToFit`'s job, downstream). A `historyBudget` of `0` scores utilization as `0` (maximally * permissive floor). */ historyBudget: number; /** Override for {@link RELEVANCE_FLOOR_MIN}. Overriding without re-running the oracle calibration * forfeits the calibrated guarantee — see that constant's TSDoc before changing it. */ floorMin?: number; /** Override for {@link RELEVANCE_FLOOR_MAX}. Same caveat as `floorMin`. */ floorMax?: number; /** Override for {@link RELEVANCE_FLOOR_CURVE}. Same caveat as `floorMin`. */ floorCurve?: number; } /** * Select which turns to replay by RELEVANCE to the current query: always keep the most recent * `keepRecent` turns, and among the OLDER turns keep any whose {@link relevanceToQuery} score against * the query's content words clears a floor that scales with how much of the older-history budget is * already spoken for (see {@link scaledRelevanceFloor}). Walks the ENTIRE history — there is no * turn-count cap — so a turn from far in the past survives if it is genuinely relevant, which a * recency-only policy (see {@link selectNaiveTurns}) can never do. * * @param turns - The full grouped history, chronological, from {@link groupHistoryIntoTurns}. * @param queryText - The current user query (or turn text) to score every older turn against. * @param options - See {@link SelectRelevantTurnsOptions}. * @returns The surviving turns, in their original chronological order. */ export declare function selectRelevantTurns(turns: ReadonlyArray>, queryText: string, options: SelectRelevantTurnsOptions): Array>; /** * Options for {@link selectNaiveTurns}. */ export interface SelectNaiveTurnsOptions { /** REQUIRED. Measures a turn's combined `qa` text's token cost. */ estimateTokens: EstimateTokensFn; /** The encoding to measure under. Default: `'cl100k_base'`. */ encoding?: string; } /** * The recency (FIFO) baseline: keep the newest turns, oldest-first-dropped, until `historyBudget` is * exhausted — walking backward from the newest turn and stopping (WITHOUT including) the first turn * that would push the running total over budget. The single newest turn is always kept even if it * alone exceeds `historyBudget`. * * @remarks * This function exists as an honest COMPARISON BASELINE, not a recommended default — it is the * "before" arm the flagship reference agent's evaluation measured {@link selectRelevantTurns} against. * The evaluation's own honest finding: naive recency is not merely worse in the general case, it can * COLLAPSE entirely on a reasoning-heavy model under enough context pressure — one matrix cell * observed the naive baseline degrade to keeping essentially nothing useful (effectively `floor 0.08` * / `3` turns of real signal) once the window filled, while relevance selection kept answering * correctly at the same pressure. Use `selectRelevantTurns` unless you specifically need a * recency-only policy or are reproducing that comparison yourself. * * @param turns - The full grouped history, chronological. * @param historyBudget - The token budget to fit turns into, newest-first. * @param options - See {@link SelectNaiveTurnsOptions}. * @returns The surviving turns, in their original chronological order. */ export declare function selectNaiveTurns(turns: ReadonlyArray>, historyBudget: number, options: SelectNaiveTurnsOptions): Array>;