import { D as Decision, d as Strategy, L as Limiter, S as Store } from './types-DKirIBQt.js'; import { h as ReplayTrace, g as ReplayStep, e as ReplayRefusal, d as ReplayFingerprint } from './recorder-85_KFgH5.js'; export { R as RecordOptions, a as Recording, b as ReplayAxis, c as ReplayClockSource, f as ReplayRefusedError, S as StrategyIdentity, T as TRACE_FORMAT_VERSION, i as assertWellFormedTrace, j as fingerprint, l as luaSha1, p as parseTrace, r as recordLimiter, s as serializeTrace } from './recorder-85_KFgH5.js'; import { LimiterSpec, ConfigStrategy } from './config.js'; import { M as ManualClock } from './clock-CnB6yaAt.js'; import './quota-C4WEn9R6.js'; /** * A field of the declarative {@link LimiterSpec} a candidate may change. A **closed union** over the real * spec keys: an unknown field is a *compile* error at the typed {@link set}/{@link scale} call sites, and * a *runtime* refusal (`candidate-invalid`) for an untyped/JS caller — never a silent no-op (design §7). */ type SpecPath = keyof LimiterSpec; /** Override one field to an explicit value. */ interface SetOp { readonly kind: "set"; readonly path: SpecPath; readonly value: unknown; } /** Multiply one numeric field by `factor`, resolved against the **base** (recorded) spec value. */ interface ScaleOp { readonly kind: "scale"; readonly path: SpecPath; readonly factor: number; } /** Change the strategy (a cross-strategy candidate), supplying the new strategy's fields. */ interface SwapOp { readonly kind: "swap"; readonly strategy: ConfigStrategy; readonly fields: Partial; } /** One delta against the recorded spec. */ type CandidateOp = SetOp | ScaleOp | SwapOp; /** * Override `path` to `value`. Type-checked: `value` must match the field's declared type, so an unknown * field or a wrong-typed value is a compile error. * * @example set("limit", 200) */ declare function set(path: K, value: LimiterSpec[K]): SetOp; /** * Scale a numeric field by `factor`, resolved against the **base** value (so it never compounds off * another op). `scale("limit", 0.5)` halves the recorded limit. Exact multiplication — no silent * rounding; combine with {@link set} if you need an integer. A non-numeric base or non-finite factor is * refused (`candidate-invalid`) when the candidate is resolved. * * A fractional result (e.g. `limit 1.5`) is applied **verbatim** and replayed faithfully — `buildStrategy` * accepts a positive non-integer ceiling, so the candidate reflects exactly what that config would do in * production (the non-integer is honoured, not rounded). Use {@link set} for an explicit integer. * * @example scale("limit", 2) */ declare function scale(path: SpecPath, factor: number): ScaleOp; /** * Swap the strategy, supplying the new strategy's fields — a **cross-strategy** candidate (e.g. * `fixedWindow → tokenBucket`). The new strategy's required fields must be provided, or the candidate is * refused at rebuild (a `swap` to `tokenBucket` needs `capacity` + `refillPerSec`). Sugar for a `set` of * `strategy` plus the fields; classified `cross-strategy` because the resolved strategy differs. * * @example swap("slidingWindow", { buckets: 4 }) */ declare function swap(strategy: ConfigStrategy, fields?: Partial): SwapOp; /** A named bundle of deltas — one what-if the scorecard scores against the recorded trace. */ interface Candidate { readonly name: string; readonly ops: readonly CandidateOp[]; } /** Name a bundle of {@link CandidateOp}s. */ declare function candidate(name: string, ...ops: CandidateOp[]): Candidate; /** * How a candidate compares to the baseline — which scorecard columns are rankable for it. * * - `comparable` — same strategy; every column (incl. strategy-specific `retryAfterMs`/`remaining`) * is meaningfully comparable to the baseline. * - `cross-strategy` — the strategy changed; only the strategy-agnostic columns (admit/deny) compare, * because `retryAfterMs`/`remaining` have different meaning across algorithms. * * The comparability unit is the strategy **name**: two `quota` cadences (e.g. `rolling` vs `fixed`) are * classed `comparable` even though `rolling` delegates to `slidingWindow` internally — they expose the * same `Decision` units, so ranking their columns is meaningful. * * (A `cross-axis` class would require multi-axis admitters; that is the deferred composite/server tier, * not reachable from the flat library `LimiterSpec` — so v1 never emits it.) */ type ComparabilityClass = "comparable" | "cross-strategy"; /** A candidate resolved into a concrete spec + its comparability class. */ interface ResolvedCandidate { readonly spec: LimiterSpec; readonly class: ComparabilityClass; } /** * Apply a candidate's deltas to the trace's recorded spec, returning the concrete candidate spec and its * {@link ComparabilityClass}. Fail-loud (`candidate-invalid`): an unknown field, more than one op on a * field (compounding is ambiguous), or a `scale` over a non-numeric base is refused — never silently * dropped. The resulting spec is still validated when {@link replay} rebuilds it (an unbuildable swap is * refused there). * * @experimental Part of the opt-in replay testkit (see STABILITY.md). */ declare function resolveCandidate(trace: ReplayTrace, cand: Candidate): ResolvedCandidate; /** * The five frozen {@link Decision} fields compared for divergence. This is deliberately a fixed field * list, **not** a structural deep-equal: `Decision` is a producer type that may grow by appending * optional fields (STABILITY.md), and a future optional field must never make a faithful replay look * divergent. Mirrors the `differs()` predicate the #286 cross-store gate uses. */ type DecisionField = "allowed" | "limit" | "remaining" | "resetAt" | "retryAfterMs"; /** One field that differs between the recorded and replayed {@link Decision}. */ interface FieldDiff { readonly field: DecisionField; readonly recorded: number | boolean; readonly replayed: number | boolean; } /** Field-level diff of two Decisions. An empty array means bit-identical on the frozen fields. */ declare function diffDecision(recorded: Decision, replayed: Decision): FieldDiff[]; /** A step at which replay diverged from the recording. */ interface DivergenceStep { readonly index: number; readonly key: string; readonly cost: number; readonly at: number; readonly diffs: readonly FieldDiff[]; /** True when `allowed` flipped — the headline what-if signal (an allow became a deny, or vice-versa). */ readonly flipped: boolean; } /** The result of comparing a replay's decisions against the recorded ones. */ interface DivergenceReport { /** Steps compared (`min(recorded, replayed)` — equal by construction for an engine replay). */ readonly total: number; /** Steps with at least one differing field. */ readonly divergent: number; /** Steps where `allowed` flipped — the count that matters for a what-if. */ readonly flipped: number; /** Index of the first divergent step, or `-1` when none. */ readonly firstDivergenceIndex: number; /** The divergent steps only (an identical replay yields `[]`). */ readonly steps: readonly DivergenceStep[]; } /** Compare recorded steps against a replay's decisions, returning a {@link DivergenceReport}. */ declare function divergence(recorded: readonly ReplayStep[], replayed: readonly Decision[]): DivergenceReport; /** Whether a report shows zero divergence. */ declare function isIdentical(report: DivergenceReport): boolean; /** * The identity self-check, as an assertion: throw `identity-divergence` unless `report` is fully * identical. Used after replaying the **recorded** spec — a non-zero divergence there means the * determinism substrate is broken, so any what-if built on it is meaningless and must be refused. */ declare function assertAcceptable(report: DivergenceReport): void; /** * Whether a metric is computed exactly or approximately. v1 metrics are all **exact** (replay output is * bounded, so an exact pass is cheap); the tag keeps the bit-exact columns structurally separate from any * future sketch-backed column (design §7 — never contaminate the exact core with a fuzzy number). */ type MetricKind = "exact" | "approx"; /** * Across which candidates a metric is meaningfully comparable. * * - `any` — comparable across *any* strategy (an admit/deny decision means the same everywhere). * - `same-strategy` — only comparable within one strategy: `retryAfterMs`/`remaining` have strategy- * specific meaning (GCRA smooth pacing vs fixed-window time-to-edge), so the scorecard * reports but does not rank them across a strategy change. */ type ComparableAcross = "any" | "same-strategy"; /** * A named metric over a decision stream. `reduce` folds the (bounded) replayed `Decision`s to one number. * The bounded `reduce(array)` form is v1; a streaming `{ init; observe; finalize }` reducer + a * sketch-backed approximate reducer are the reserved seam for unbounded / fleet data (Phase B). * * @experimental Part of the opt-in replay testkit (see STABILITY.md). */ interface ScoreReducer { /** Stable identifier (the scorecard column id). */ readonly id: string; /** Exact or approximate (v1: always `"exact"`). */ readonly kind: MetricKind; /** Across which candidates this column may be ranked. */ readonly comparableAcross: ComparableAcross; /** Fold a decision stream to one number. */ reduce(decisions: readonly Decision[]): number; } /** * Exact `p`-quantile (0..1) by **nearest-rank** over already-sorted ascending `values`. Empty ⇒ 0. For * `p=0.99` of a short array this returns the max — the honest "worst observed", not an interpolation. */ declare function quantile(sorted: readonly number[], p: number): number; /** Fraction of decisions that were admitted (0..1). Exact; comparable across any strategy. */ declare const allowRate: ScoreReducer; /** Count of admitted decisions. Exact; comparable across any strategy. */ declare const allowCount: ScoreReducer; /** Count of denied decisions. Exact; comparable across any strategy. */ declare const denyCount: ScoreReducer; /** * p99 of `retryAfterMs` over all decisions. Exact; **same-strategy** — the retry-after a strategy emits * is algorithm-specific, so it is reported but not ranked across a strategy change. */ declare const retryP99: ScoreReducer; /** * Median `remaining` over all decisions. Exact; **same-strategy** — `remaining` counts a strategy- * specific budget (window slots vs token-bucket tokens), so it is reported but not ranked across strategies. */ declare const remainingP50: ScoreReducer; /** The default scorecard columns: admit/deny (any-strategy) + retry/remaining (same-strategy). */ declare const DEFAULT_REDUCERS: readonly ScoreReducer[]; /** * Directional admit/deny flips vs the recording — the exact, strategy-agnostic headline of a what-if. * `allowedToDenied` = a request the recording admitted that the candidate denies (a *tightening*); * `deniedToAllowed` = the reverse (a *loosening*). `total` equals `DivergenceReport.flipped`. */ interface DirectionalFlips { readonly allowedToDenied: number; readonly deniedToAllowed: number; readonly total: number; } /** One scored metric for one row. `comparable:false` ⇒ reported for context but not rankable for this row. */ interface ScoreColumn { readonly id: string; readonly value: number; readonly kind: ScoreReducer["kind"]; /** Whether this column is meaningfully comparable to the baseline for this row's class. */ readonly comparable: boolean; } /** A loud refusal: the candidate's delta or rebuilt spec was invalid — never a silent zero-change row. */ interface CandidateRefusal { readonly reason: ReplayRefusal; readonly message: string; } /** One candidate's row in the scorecard. */ interface ScorecardRow { readonly name: string; readonly class: ComparabilityClass; readonly status: "ok" | "refused"; /** Present when `status === "refused"` — the machine-readable reason + message. */ readonly refusal?: CandidateRefusal; /** The resolved candidate spec (present once the delta resolved, even if rebuild later failed). */ readonly spec?: LimiterSpec; /** Directional admit/deny flips vs the recording (present when `status === "ok"`). */ readonly flips?: DirectionalFlips; /** * Steps differing on **any** decision field vs the recording (present when `status === "ok"`). Broader * than {@link DirectionalFlips}: it includes strategy-specific field noise (raising `limit` shifts * `remaining` on every step), so it is context, not the headline. `flips` is the admit/deny signal. */ readonly divergent?: number; /** Scored columns over the candidate's replayed decisions (empty when refused). */ readonly columns: readonly ScoreColumn[]; } /** The result of comparing candidates against a recorded trace. */ interface Scorecard { /** Columns scored over the recorded decisions — the reference every row is read against. */ readonly baseline: { readonly columns: readonly ScoreColumn[]; }; /** One row per candidate, in input order. */ readonly rows: readonly ScorecardRow[]; } /** Options for {@link scorecard}. */ interface ScorecardOptions { /** Metrics to score (default {@link DEFAULT_REDUCERS}). */ readonly reducers?: readonly ScoreReducer[]; } /** * Score a list of candidate what-ifs against a recorded decision trace. * * The trust precondition runs **once**: an identity self-check (replay the recorded spec, assert it * reproduces the recording bit-for-bit). If it fails — or the trace is itself unreplayable (truncated / * malformed / non-rate) — `scorecard` **throws** (the whole comparison is untrustworthy; it does not emit * misleading rows). Each candidate is then scored **failure-isolated**: an ill-formed delta or an * unbuildable spec becomes a loud `refused` row carrying the reason, never a silent zero-change result, so * one bad candidate does not sink the batch. * * Each row carries the exact, strategy-agnostic {@link DirectionalFlips} headline plus the reducer * columns; a `cross-strategy` row's strategy-specific columns are flagged `comparable:false` (reported, * not ranked). * * @experimental Part of the opt-in replay testkit (see STABILITY.md). */ declare function scorecard(trace: ReplayTrace, candidates: readonly Candidate[], options?: ScorecardOptions): Scorecard; /** * The successfully-scored rows, ordered by **most behavioural change first** (descending total flips) — * the universally-valid ranking (admit/deny flips compare across any strategy). Refused rows are omitted; * read their `refusal` directly. Strategy-specific columns remain only comparable within a class. */ declare function rankByFlips(card: Scorecard): readonly ScorecardRow[]; interface ReplayOptions { /** * A candidate spec — the what-if. Omit for an **identity** replay (rebuild the recorded spec and * confirm it reproduces the recording bit-for-bit). With a candidate, the divergence of the * candidate's decisions from the recording is the result. */ readonly candidate?: LimiterSpec; /** * Skip the identity self-check that runs before a candidate replay. OFF by default — the self-check * is the trust precondition: it proves the trace replays faithfully, so any candidate divergence is * attributable to the candidate, not a broken substrate. */ readonly skipIdentityCheck?: boolean; } interface ReplayResult { /** The decisions re-derived by replay (against the candidate if given, else the recorded spec). */ readonly replayed: readonly Decision[]; /** Divergence of `replayed` from the recorded decisions. Empty for an identity replay. */ readonly divergence: DivergenceReport; /** The spec replay ran (`candidate` when given, else the recorded spec). */ readonly spec: LimiterSpec; /** Whether a candidate (what-if) spec was used. */ readonly isCandidate: boolean; } /** * Replay a decision trace. Two modes: * * - **Identity** (no candidate): rebuild the recorded spec, reproduce the recording, and assert zero * divergence. A non-zero divergence throws `identity-divergence` — the determinism substrate is * broken. This is the v1 deliverable's core guarantee. * - **Candidate** (a what-if spec): first run the identity self-check (unless `skipIdentityCheck`), so * the reported divergence is attributable to the candidate; then rebuild the candidate, reproduce the * same `(key, cost, at)` stream, and return the divergence (its `flipped` count is the headline * "how many requests would this change"). * * Pairs with {@link candidateField} for the single-field what-if of v1. * * @experimental Part of the opt-in replay testkit (see STABILITY.md). */ declare function replay(trace: ReplayTrace, options?: ReplayOptions): ReplayResult; /** * Single-field what-if: clone the trace's recorded spec with exactly **one** field overridden — the * v1 candidate form (a multi-field DSL is P2/#288). Type-checked to a real `LimiterSpec` field; the * resulting spec is validated when {@link replay} rebuilds it (e.g. an unrebuildable strategy is * refused). * * @example * const result = replay(trace, { candidate: candidateField(trace, "limit", 200) }); * console.log(`${result.divergence.flipped} request(s) would change at limit=200`); */ declare function candidateField(trace: ReplayTrace, field: K, value: LimiterSpec[K]): LimiterSpec; /** Whether `name` is a strategy `buildStrategy` can construct (and therefore replay can rebuild). */ declare function isRebuildableStrategy(name: unknown): name is ConfigStrategy; /** * Refuse, loudly, any fingerprint that cannot be deterministically and faithfully replayed. Each * refusal is a distinct `ReplayRefusal`. When `rebuilt` is supplied, the two cross-checks that need * the actually-rebuilt strategy run as well: identity (name/limit/window) and Lua-SHA-1 — these catch * a tampered trace or a library build that drifted from the one that produced it. */ declare function assertReplayable(fp: ReplayFingerprint, rebuilt?: Strategy): void; /** * Refuse a trace that is structurally unreplayable before any rebuild work: truncated (partial), * empty (nothing to replay), or whose fingerprint is itself unreplayable. The strategy/Lua * cross-checks need the rebuilt strategy and run later, inside the engine. */ declare function assertReplayableTrace(trace: ReplayTrace): void; interface RebuildOptions { /** * The {@link ManualClock} replay drives — `set()` to each step's instant. The rebuilt store reads * this same instance, so decisions and TTL/expiry key off one time base (the #286 §4.5 invariant). */ readonly clock: ManualClock; /** Key prefix; match the trace's recorded prefix so store keys line up exactly. */ readonly prefix?: string; /** Config name — used only for `buildStrategy` error context. */ readonly name?: string; } /** * Rebuild the exact leaf limiter a trace was recorded over (or a candidate spec) on a **fresh, * deterministic** store: `MemoryStore({ clock, sweepIntervalMs: 0 })` reading the same * {@link ManualClock} as the limiter. Two construction facts (pinned by the #286 store-invariant gate) * make replay reproducible: no wall-clock sweep timer, and one shared time base. The strategy comes * from the single source of truth, {@link buildStrategy}, so the rebuild is behaviourally identical to * the recording. * * Refuses (`unrebuildable-strategy`) any spec whose strategy `buildStrategy` cannot construct — e.g. * `leakyBucket` or a composite/unknown strategy — with a replay-specific message, before the generic * config error would fire. */ declare function rebuildLimiter(spec: LimiterSpec, options: RebuildOptions): Limiter; /** * A reusable conformance suite any store author can run against their {@link Store}. It pins the * exact contract the limiter relies on — persistence, key isolation, reset, TTL expiry, and (the * load-bearing one) atomic read-modify-write under concurrency — so a new backend is "correct" * the moment this suite is green. */ /** * The slice of a test framework the conformance suite needs. Pass your runner's functions * (`vitest`, `jest`, `node:test` via thin shims, …). Decoupling like this keeps `throttlekit/testkit` * import-safe outside a test process and framework-agnostic. */ interface TestHarness { describe(name: string, fn: () => void): void; it(name: string, fn: () => void | Promise): void; beforeEach(fn: () => void | Promise): void; afterEach(fn: () => void | Promise): void; expect(actual: unknown): { toBe(expected: unknown): void; }; } /** Everything {@link runStoreConformance} needs to exercise one store implementation. */ interface StoreTestContext { /** A fresh store under test. */ store: Store; /** * Move the store's clock forward by `ms`. Stores driven by a real server clock (e.g. Redis) * cannot time-travel; they pass a no-op here and set {@link StoreTestContext.supportsTimeTravel} * to `false` so the TTL test skips its assertion. */ advance(ms: number): void; /** * Whether {@link StoreTestContext.advance} actually moves the store's notion of time. Defaults * to `true`; set `false` for stores backed by an uncontrollable clock so the TTL-expiry test is * skipped rather than failing. */ supportsTimeTravel?: boolean; /** Release any resources (connections, timers) opened by the context. */ teardown?(): Promise | void; } /** * Register the store-conformance suite under `describe(name)`. `setup` is invoked fresh in * `beforeEach`, so each test gets an isolated store; the context's `teardown` (if any) runs in * `afterEach`. * * @example * import { describe, it, expect, beforeEach, afterEach } from "vitest"; * runStoreConformance("MemoryStore", () => { * const clock = new ManualClock(0); * return { store: new MemoryStore({ clock, sweepIntervalMs: 0 }), advance: (ms) => clock.advance(ms) }; * }, { describe, it, expect, beforeEach, afterEach }); */ declare function runStoreConformance(name: string, setup: () => StoreTestContext | Promise, harness: TestHarness): void; export { type Candidate, type CandidateOp, type CandidateRefusal, type ComparabilityClass, type ComparableAcross, DEFAULT_REDUCERS, type DecisionField, type DirectionalFlips, type DivergenceReport, type DivergenceStep, type FieldDiff, type MetricKind, type RebuildOptions, ReplayFingerprint, type ReplayOptions, ReplayRefusal, type ReplayResult, ReplayStep, ReplayTrace, type ResolvedCandidate, type ScaleOp, type ScoreColumn, type ScoreReducer, type Scorecard, type ScorecardOptions, type ScorecardRow, type SetOp, type SpecPath, type StoreTestContext, type SwapOp, type TestHarness, allowCount, allowRate, assertAcceptable, assertReplayable, assertReplayableTrace, candidate, candidateField, denyCount, diffDecision, divergence, isIdentical, isRebuildableStrategy, quantile, rankByFlips, rebuildLimiter, remainingP50, replay, resolveCandidate, retryP99, runStoreConformance, scale, scorecard, set, swap };