import type { Config } from "./config-types.js"; /** * Longest window either memory is held for, whatever a caller asks. The ceiling is not cosmetic: * a pin is a preference recorded from ONE success, and a demotion from ONE fresh failure signal. Neither * observation supports parking the ladder in a shape for a day. `dispatch.ts`'s `MAX_EXHAUSTED_MS` * is 30 days because a vendor STATES a quota window; nothing states one here. */ export declare const MAX_AFFINITY_MS: number; /** Pin window when config names none: long enough to cover a burst of related tasks. */ export declare const DEFAULT_PIN_MS: number; /** Demotion window when config names none. Same default as the pin — one failure signal, one turn. */ export declare const DEFAULT_DEMOTE_MS: number; /** One live memory: the lane it names, when it lapses, and the evidence that created it. */ export interface LaneAffinityRow { /** Ladder tier this memory belongs to, or null for the legacy single ladder. */ tier: string | null; laneId: string; /** `pin` promotes this lane; `demote` orders it behind lanes carrying no demotion. */ kind: LaneAffinityKind; /** Epoch ms at which the memory lapses. */ until: number; /** Why this memory exists, in words a ladder reader can act on. */ reason: string; } /** * The two memories, as a closed set derived from ONE `as const` array. A hand-copied second list * is the most-repeated defect in this repository's history; deriving the type from the list means * a third memory is a compile error at `LANE_AFFINITY_DEFAULT_TTL_MS` below rather than a silent * drop at a loader. */ export declare const LANE_AFFINITY_KINDS: readonly ["pin", "demote"]; export type LaneAffinityKind = (typeof LANE_AFFINITY_KINDS)[number]; /** * Default window per memory kind — the ONE place that chooses a number per kind, as a total * table closed with `satisfies` (the `buildAuthHeaders` precedent in `src/authEnv.ts`). A third * memory kind is a compile error HERE, at the table, rather than a silent drop wherever the new * kind's default was forgotten. `pinLane`/`demoteLane` read their fallback through * `defaultWindowFor` so no second per-kind branch can drift out of step with this one. */ export declare const LANE_AFFINITY_DEFAULT_TTL_MS: { pin: number; demote: number; }; /** Pin a lane that answered, so the next dispatch on this tier takes it first. */ export declare function pinLane(cfg: Config, tier: string | null, laneId: string, reason: string, ttlMs?: number, now?: number): void; /** Demote a lane on fresh negative evidence (including an idle-stop), so ready lanes lead it. */ export declare function demoteLane(cfg: Config, tier: string | null, laneId: string, reason: string, ttlMs?: number, now?: number): void; /** This lane's live pin on this tier, or null. */ export declare function lanePin(cfg: Config, tier: string | null, laneId: string, now?: number): LaneAffinityRow | null; /** This lane's live demotion on this tier, or null. */ export declare function laneDemotion(cfg: Config, tier: string | null, laneId: string, now?: number): LaneAffinityRow | null; /** * Tunables for the recent-versus-earlier outlier demotion (backlog item 9, owner question * 2026-09-05: demote a lane whose RECENT distribution is an outlier against its OWN earlier * history, on a threshold calibrated from that history). * * `DEFAULT_OUTLIER_FACTOR` (7.6) is the CALIBRATED default — `scripts/calibrate-lane-outlier.mjs`, * run 2026-09-09 21:37Z against this machine's `~/.llm-relay/dispatch-lane-stats.json`: 3 (lane, * tier) windows with enough history (agy-gemini 25 samples, free-pool 100, opencode-muse-spark * 57), 155 sliding recent-median / history-p80 ratios, pooled p50 0.62, p90 2.03, p95 7.57, * max 33.32. The method is the pooled p95 — the point above which a lane is more extreme than * 95% of its own-history comparisons — ACCEPTED inside the script's [1.5, 10.0] band and rounded * to one decimal. * * ⚠ **A first draft of this comment reported a run that never happened** (p95 1.44, rejected as * too LOW, default 2.5): the script did not exist in the tree when it was written. The real run * says the opposite. Per lane, free-pool's HEALTHY ratio reaches 4.18 at p95 (max 33.32) and * opencode-muse-spark's 12.54 (max 13.65) — a lane here legitimately runs an agent loop whose * wall clock swings several-fold — so a factor of 2.5 would have demoted both on ordinary wobble, * the exact harm this rule must not do, and the band's upper edge moved from 5.0 to 10.0 on that * evidence. 7.6 still fires on the real 13x and 33x events in the same window. * * ⚠ Figures are PERISHABLE — they describe one machine's traffic on one date. Re-run the script * as lane history accumulates and move this constant with it; never quote these as measurements * of anything but that window. The parser mirrors the three defaults in * `DEFAULT_DISPATCH_WALK_OUTLIER` (`config/routing-parser.ts`), pinned equal by a test. */ export declare const DEFAULT_OUTLIER_RECENT_COUNT = 5; export declare const DEFAULT_OUTLIER_HISTORY_QUANTILE = 0.8; export declare const DEFAULT_OUTLIER_FACTOR = 7.6; /** Resolved outlier settings: the operator's overrides with the defaults above filled in. */ export interface LaneOutlierSettings { recentCount: number; historyQuantile: number; outlierFactor: number; minSamples: number; } /** What the rule found, carrying the evidence that produced it so the reason can state it. */ export interface LaneOutlierEvidence { recentMedianMs: number; historyMs: number; } /** * Recent-versus-earlier outlier test on ONE lane's OWN (lane, tier) window, oldest first. * * The window splits into the most recent `recentCount` samples and the rest; the lane is an * outlier when the recent MEDIAN exceeds the earlier window's `historyQuantile` by more than * `outlierFactor`. Both halves need at least `minSamples` samples or the rule is silent — * unmeasured is no opinion, never "slow" (the `latency-demotion.ts` rule). Nearest-rank * quantiles only, reused from `dispatch-lane-stats.ts`, never re-implemented: a percentile * reporting a duration nothing ever took would be a fabricated measurement. * * ⚠ The statistic is meaningful only because each sample is attributable in TIME * (`wallClockAt` in `dispatch-lane-stats.ts`): "recent" is the tail of the window, not a * subsample. And no HTTP-path number is borrowed here — a lane legitimately runs an agent * loop for minutes, so pointing 250 ms/token or a 30 s ceiling at it would demote every * healthy lane at once. */ export declare function checkLaneOutlier(samples: readonly number[], settings: LaneOutlierSettings): LaneOutlierEvidence | null; /** * The demotion reason for an outlier hit. It names BOTH figures and the factor — a reason * string is a claim about the ordering code, printed on the ladder view, so an operator * reading it can see the measurement rather than taking the demotion on faith. */ export declare function outlierDemotionReason(evidence: LaneOutlierEvidence, opts: { historyQuantile: number; outlierFactor: number; }): string; /** * Evaluate the outlier rule for one (lane, tier) window and, on a hit, demote through the * SHARED entry: retract first (`clearLaneAffinity`), then `demoteLane` — the same * retract-then-record sequence `recordLaneAffinity` uses for a walk demotion, so an outlier * demotion retracts an existing pin rather than sitting beside it. It never touches the pin * directly; the shared entry already handles that. * * Returns the reason recorded, or null when the rule is inert (`false`), silent (too little * history), or the window is steady. Pure evaluation, one write on a hit. */ export declare function recordLaneOutlier(cfg: Config, tier: string | null, laneId: string, samples: readonly number[], outlier: false | Omit, opts: { minSamples: number; demoteMs: number; }, now?: number): string | null; /** * Retract every memory for one lane on one tier. Called when a lane SUCCEEDS: the success * disproves the demotion that previous negative evidence recorded, exactly as a served 200 clears * a cooling condition in `target-facts.ts`. It does not touch other lanes or other tiers, because * one lane's success says nothing about theirs. */ export declare function clearLaneAffinity(cfg: Config, tier: string | null, laneId: string): void; /** * Retract ONE memory kind for one lane on one tier — the operator's `unpin` (`POST /dispatch * {"unpin": …}`). Narrower than `clearLaneAffinity` on purpose: an operator withdrawing a pin * they placed has said nothing about a demotion the walk recorded from its own measurement, so * that evidence stands. Returns whether a live memory of that kind existed; a lapsed row counts * as absent, because `recall` would have deleted it on the next read anyway. */ export declare function forgetLaneMemory(cfg: Config, kind: LaneAffinityKind, tier: string | null, laneId: string, now?: number): boolean; /** Every live memory for this config, lapsed rows dropped. Copies, sorted for stable rendering. */ export declare function exportLaneAffinityRows(cfg: Config, now?: number): LaneAffinityRow[]; /** * Restore persisted rows. FUTURE-ONLY, and it never overwrites a memory the live process already * learned — both are the `restoreExhaustedRows` contract. A lapsed row is not restored at all: a * preference recorded before a restart, whose window has since passed, is history rather than * routing state. Returns the count restored. */ export declare function restoreLaneAffinityRows(cfg: Config, rows: readonly LaneAffinityRow[], now?: number): number; export declare function onLaneAffinityChanged(cfg: Config, listener: () => void): void; /** Bumped when the row shape changes; a mismatch restores nothing rather than guessing. */ export declare const CURRENT_LANE_AFFINITY_VERSION = 1; export interface LaneAffinityFile { version: number; rows: LaneAffinityRow[]; } export declare function getLaneAffinityPath(): string; /** * Read the persisted rows. Absent file, unreadable file, wrong version, or an unrecognized * envelope all yield an empty list — never a throw, never a partial row from a shape we did not * recognize. One malformed row is dropped alone: the `lane-manifest.ts` shallow-validation * regression, where a corrupt file EVICTED a healthy lane, is the standing warning. */ export declare function loadLaneAffinityRows(opts?: { path?: string; }): LaneAffinityRow[]; export declare function saveLaneAffinityRows(rows: readonly LaneAffinityRow[], opts?: { path?: string; }): void; /** * Wire a config's lane memories to the file: restore what is still live, then flush on every * change, debounced through the shared `WriteBehindTimer`. Returns the number restored. */ export declare function installLaneAffinityPersistence(cfg: Config, opts?: { path?: string; }): number; /** * The shutdown seam: write every dirty memory NOW. Until 2026-09-08 nothing could — `runProxy` * flushed six sibling stores at shutdown and this one held its timer in a closure nobody could * reach, so a pin or demotion learned in the last two seconds died with the process. * Returns how many files were written. */ export declare function flushLaneAffinityPersistence(): number; /** Test seam: forget every memory for this config. Never called from `src/`. */ export declare function resetLaneAffinity(cfg: Config): void;