tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it./home/user/instruction.md
1 # Ticket: Implement the Tidal credit limiter
2
3 ## Context
4
5 `rate-limiter` is the throttling toolkit our API gateway uses to enforce
6 per-client quotas (TypeScript, Node 20). It already ships a token bucket and an
7 exact sliding-window log, both provided complete.
8
9 We need a limiter for clients whose traffic is bursty but who we are willing to
10 let run a *controlled* deficit rather than hard-refusing during a spike. It is
11 the **Tidal credit limiter**: a client's spendable balance ebbs and refills
12 continuously, and a client may dip below the waterline (into debt) up to a fixed
13 depth before the tide has to come back in.
14
15 Its rules are **deliberately not** those of a token bucket, leaky bucket (GCRA),
16 or sliding window. Implement exactly what is written below.
17
18 ## Your task
19
20 Implement the single unimplemented method:
21
22 ```
23 src/limiter/tidal.ts -> TidalRateLimiter.tryAcquire(key, cost = 1): TidalResult
24 ```
25
26 The class, its options, validation, the per-key state map and `reset` are
27 already written. The decision logic is yours. `npm run typecheck` must stay
28 clean and you must not change the public surface (the constructor, option names,
29 exported types, or `reset`).
30
31 ## What the limiter must do
32
33 Each **key** holds its spendable balance in **two pools** plus an owed balance,
34 all measured in the same units as `cost`:
35
36 - a **burst pool** , fast and shallow. Capacity `Cb = capacity * burstFraction`,
37 refilling from empty to `Cb` over `burstWindowMs` (rate `rb = Cb / burstWindowMs`
38 per ms).
39 - a **sustained pool** , slow and deep. Capacity `Cs = capacity * (1 - burstFraction)`,
40 refilling from empty to `Cs` over `sustainedWindowMs` (rate `rs = Cs / sustainedWindowMs`
41 per ms). When `Cs = 0` its rate is `0`.
42 - an **owed balance** , how much the key currently owes from past borrowing.
43
44 The two pool capacities sum to `capacity`; the **spendable balance** is
45 `burst + sustained`. A fresh key starts with both pools full and owes nothing.
46 Neither pool ever exceeds its own capacity.
47
48 `burstFraction` is in `(0, 1]` and defaults to `1`. `burstWindowMs` and
49 `sustainedWindowMs` each default to `windowMs`. `repayFraction` is in `(0, 1]`
50 and defaults to `1`. `overdraft` is in `[0, 1)` and defaults to `0`. With the
51 defaults the sustained pool vanishes and the limiter reduces to a single pool of
52 size `capacity` refilling over `windowMs` , the original single-tier behavior.
53
54 ### Replenishment (how the balances change over time)
55
56 Replenishment is **continuous, time-based and concurrent**: both pools accrue at
57 the same instant, each at its own rate, each capped at its own capacity. Accrual
58 depends only on elapsed time, never on whether requests were made.
59
60 How accrual is applied depends on whether the key owes anything:
61
62 - If the key owes **nothing**, the burst pool gains `rb` per ms and the sustained
63 pool gains `rs` per ms (each clamped to its capacity).
64 - If the key **owes** something, a fixed proportion `repayFraction` of the
65 **combined** inflow (`rb + rs` per ms) is diverted to reduce the owed balance,
66 and each pool nets the remaining `(1 - repayFraction)` of its own rate. The
67 amount directed at the owed balance is only ever as large as the owed balance
68 itself: the instant the debt reaches zero, accrual reverts to the
69 owes-nothing rule for the rest of the elapsed time. (So `repayFraction`
70 controls *only* how inflow is shared while a debt exists.)
71
72 Because the pools saturate at different times and the debt can clear partway
73 through, the spendable balance grows piecewise-linearly with breakpoints at
74 debt-clear, burst-full and sustained-full.
75
76 ### Admission (how a request is decided)
77
78 When `tryAcquire(key, cost)` is called, first bring the balances up to the
79 current time, then decide:
80
81 1. If the **spendable balance** (`burst + sustained`) covers `cost`, admit and
82 deduct `cost`, **spending the burst pool first, then the sustained pool**.
83 The owed balance is untouched.
84 2. Otherwise the request must **borrow** the shortfall (`cost` minus the
85 spendable balance). Borrowing is allowed only while the owed balance would
86 stay within the **borrowing limit** `capacity * overdraft`. If the shortfall
87 fits, admit: drive **both pools to zero** and add the shortfall to the owed
88 balance.
89 3. If even borrowing the shortfall would push the owed balance past the
90 borrowing limit, **refuse**.
91
92 With `overdraft = 0` there is no borrowing limit to speak of (the key can never
93 owe anything).
94
95 ### Refusal semantics
96
97 A refused request **consumes nothing**: it changes neither pool, the owed
98 balance, nor anything else beyond advancing the key's bookkeeping to the current
99 time. Two refusals at the same instant must be identical.
100
101 ### Validation
102
103 `cost` must be a finite number `>= 1`; otherwise throw
104 `RateLimitError("invalid_cost", ...)` **before** touching any state. Keys are
105 fully isolated.
106
107 ## Return contract , `TidalResult`
108
109 - `allowed` , whether the request was admitted.
110 - `limit` , the configured `capacity`.
111 - `remaining` , whole units still drawable from the spendable balance (both
112 pools) immediately after this decision, never negative (round down).
113 - `debtRemaining` , whole units the key still owes immediately after this
114 decision, never negative (round up); `0` when it owes nothing.
115 - `retryAfterMs` , `0` when `allowed`. On a refusal, the **smallest whole number
116 of milliseconds** such that, if the caller waited exactly that long and retried
117 **the same `cost`** (with no other traffic on the key in between), the retry
118 would be admitted , and waiting any whole millisecond less would still be
119 refused. This is the exact minimum wait under the two-tier refill and repay
120 dynamics above; it generally steps across the refill breakpoints and has no
121 single closed form.
122 - `resetMs` , whole milliseconds from now until the key is **fully replenished**:
123 both pools at their capacities and owing nothing. `0` if already there.
124
125 Balances are real-valued; treat them as exact to within about a millionth of a
126 unit when comparing (so a `cost` exactly equal to the spendable balance is
127 covered, and an owed balance exactly at the borrowing limit is within it). The
128 whole-unit fields are the floor/ceil of those real balances.
129
130 ## Definition of done
131
132 - `npm run typecheck` is clean.
133 - The shipped smoke test (`test/smoke.test.ts`) still passes.
134 - Your `TidalRateLimiter.tryAcquire` implements the contract above. It is graded
135 by a separate, hidden suite checking admissions, balances, timing fields, edge
136 cases and randomized workloads against an independent reference.
137 - Implement `tryAcquire` only; do not modify the other files, the public surface,
138 or the provided limiters.
139
140 ## Running locally
141
142 ```bash
143 npm install # already done in the provided environment
144 npm run typecheck
145 npm test
146 ```
147/home/user/app/src/limiter/tidal.ts
1 import { RateLimitError } from "./errors.js";
2 import { systemClock } from "./clock.js";
3 import type { Clock, TidalResult } from "./types.js";
4
5 export interface TidalOptions {
6 /** Capacity: the spendable balance a fresh key starts with, and its ceiling. Finite, >= 1. */
7 capacity: number;
8 /** Window length in ms over which a full `capacity` worth of balance is replenished. Finite, >= 1. */
9 windowMs: number;
10 /**
11 * Overdraft fraction in [0, 1). Controls how deep a key may go into the
12 * negative (its borrowing limit) relative to `capacity`. Default 0 (no
13 * borrowing). See `instruction.md` for the exact borrowing contract.
14 */
15 overdraft?: number;
16 /**
17 * Repay fraction in (0, 1]. Controls how the continuous replenishment is
18 * apportioned while a key owes a balance. Default 1. See `instruction.md`.
19 */
20 repayFraction?: number;
21 /**
22 * Burst fraction in (0, 1]. Share of `capacity` held in the fast burst pool;
23 * the rest is the slow sustained pool. Default 1. See `instruction.md`.
24 */
25 burstFraction?: number;
26 /** Window (ms) over which the burst pool refills. Default `windowMs`. See `instruction.md`. */
27 burstWindowMs?: number;
28 /** Window (ms) over which the sustained pool refills. Default `windowMs`. See `instruction.md`. */
29 sustainedWindowMs?: number;
30 /** Injectable clock. Defaults to the system clock. */
31 clock?: Clock;
32 }
33
34 /** Per-key accounting state. */
35 interface TidalState {
36 /** Burst pool balance (>= 0). */
37 burst: number;
38 /** Sustained pool balance (>= 0). */
39 sustained: number;
40 /** Balance currently owed (>= 0). */
41 debt: number;
42 /** Instant (epoch ms) the balances were last advanced to. */
43 updatedAt: number;
44 }
45
46 /**
47 * Tidal credit limiter.
48 *
49 * A bespoke single-key limiter built around two replenishing spendable pools (a
50 * fast burst pool and a slow sustained pool) plus a separate owed balance. Its
51 * admission, replenishment and borrowing rules are NOT those of a textbook
52 * token bucket / leaky bucket / sliding window , implement exactly the
53 * behavioral contract documented in `instruction.md`.
54 *
55 * The constructor, validation, per-key state map and {@link reset} are provided.
56 * The decision logic in {@link tryAcquire} is the unimplemented core.
57 */
58 export class TidalRateLimiter {
59 protected readonly capacity: number;
60 protected readonly windowMs: number;
61 protected readonly overdraft: number;
62 protected readonly repayFraction: number;
63 protected readonly clock: Clock;
64 protected readonly state = new Map<string, TidalState>();
65
66 /** Burst pool capacity (`capacity * burstFraction`). */
67 protected readonly burstCapacity: number;
68 /** Sustained pool capacity (`capacity * (1 - burstFraction)`). */
69 protected readonly sustainedCapacity: number;
70 /** Burst pool refill rate, units per ms. */
71 protected readonly burstRate: number;
72 /** Sustained pool refill rate, units per ms. */
73 protected readonly sustainedRate: number;
74 /** Borrowing limit (`capacity * overdraft`). */
75 protected readonly maxDebt: number;
76
77 constructor(options: TidalOptions) {
78 if (!Number.isFinite(options.capacity) || options.capacity < 1) {
79 throw new RateLimitError("invalid_capacity", "capacity must be a finite number >= 1");
80 }
81 if (!Number.isFinite(options.windowMs) || options.windowMs < 1) {
82 throw new RateLimitError("invalid_window", "windowMs must be a finite number >= 1");
83 }
84 const overdraft = options.overdraft ?? 0;
85 if (!Number.isFinite(overdraft) || overdraft < 0 || overdraft >= 1) {
86 throw new RateLimitError("invalid_overdraft", "overdraft must be a finite number in [0, 1)");
87 }
88 const repayFraction = options.repayFraction ?? 1;
89 if (!Number.isFinite(repayFraction) || repayFraction <= 0 || repayFraction > 1) {
90 throw new RateLimitError("invalid_repay", "repayFraction must be a finite number in (0, 1]");
91 }
92 const burstFraction = options.burstFraction ?? 1;
93 if (!Number.isFinite(burstFraction) || burstFraction <= 0 || burstFraction > 1) {
94 throw new RateLimitError("invalid_burst", "burstFraction must be a finite number in (0, 1]");
95 }
96 const burstWindowMs = options.burstWindowMs ?? options.windowMs;
97 if (!Number.isFinite(burstWindowMs) || burstWindowMs < 1) {
98 throw new RateLimitError("invalid_window", "burstWindowMs must be a finite number >= 1");
99 }
100 const sustainedWindowMs = options.sustainedWindowMs ?? options.windowMs;
101 if (!Number.isFinite(sustainedWindowMs) || sustainedWindowMs < 1) {
102 throw new RateLimitError("invalid_window", "sustainedWindowMs must be a finite number >= 1");
103 }
104 this.capacity = options.capacity;
105 this.windowMs = options.windowMs;
106 this.overdraft = overdraft;
107 this.repayFraction = repayFraction;
108 this.clock = options.clock ?? systemClock;
109 this.burstCapacity = options.capacity * burstFraction;
110 this.sustainedCapacity = options.capacity * (1 - burstFraction);
111 this.burstRate = this.burstCapacity / burstWindowMs;
112 this.sustainedRate = this.sustainedCapacity > 0 ? this.sustainedCapacity / sustainedWindowMs : 0;
113 this.maxDebt = options.capacity * overdraft;
114 }
115
116 /** Drop all state for `key`. Returns true iff state existed. */
117 reset(key: string): boolean {
118 return this.state.delete(key);
119 }
120
121 /**
122 * Look up (creating if absent) the accounting state for `key`. A fresh key
123 * starts with both pools full, no owed balance, last-updated `now`.
124 */
125 protected stateFor(key: string, now: number): TidalState {
126 let s = this.state.get(key);
127 if (!s) {
128 s = { burst: this.burstCapacity, sustained: this.sustainedCapacity, debt: 0, updatedAt: now };
129 this.state.set(key, s);
130 }
131 return s;
132 }
133
134 /**
135 * Attempt to admit `cost` units (default 1) for `key` at the current time,
136 * per the behavioral contract in `instruction.md`.
137 *
138 * Validate `cost` (non-finite or `< 1` throws `RateLimitError("invalid_cost")`)
139 * before touching any state, keep keys isolated, and return a fully-populated
140 * {@link TidalResult}.
141 */
142 tryAcquire(key: string, cost = 1): TidalResult {
143 void this.capacity;
144 void this.windowMs;
145 void this.overdraft;
146 void this.repayFraction;
147 void this.clock;
148 void this.state;
149 void this.stateFor;
150 void this.burstCapacity;
151 void this.sustainedCapacity;
152 void this.burstRate;
153 void this.sustainedRate;
154 void this.maxDebt;
155 void RateLimitError;
156 void key;
157 void cost;
158
159 // TODO(limiter): implement the Tidal credit limiter contract from instruction.md.
160 throw new Error("TidalRateLimiter.tryAcquire not implemented");
161 }
162 }
163/home/user/app/src/limiter/types.ts
1 /**
2 * Types for the rate-limiter toolkit.
3 *
4 * The toolkit ships several limiter strategies behind a common surface. Two are
5 * provided complete as references:
6 *
7 * - a **token bucket** , smooth, burst-tolerant throttling that refills
8 * continuously at a fixed rate;
9 * - a **sliding-window log** , exact "at most N units per rolling window"
10 * accounting backed by per-key event timestamps.
11 *
12 * A third strategy, the {@link TidalRateLimiter}, is the unimplemented core of
13 * this exercise; see `instruction.md` for its behavioral contract.
14 *
15 * Every limiter is deterministic under an injectable {@link Clock} so that
16 * windows advance at controlled instants in tests.
17 */
18
19 export interface Clock {
20 /** Current time in epoch milliseconds. */
21 now(): number;
22 }
23
24 /**
25 * The outcome of an attempt to admit some units of work for a key.
26 *
27 * `allowed` says whether the request may proceed. The remaining fields are
28 * advisory and power `X-RateLimit-*` style response headers:
29 *
30 * - `remaining` , whole units still drawable from the key's spendable balance
31 * immediately after this decision (never negative).
32 * - `limit` , the configured capacity for the key.
33 * - `retryAfterMs` , when `allowed` is false, the soonest a retry of the same
34 * request could succeed (ms from now); `0` when allowed.
35 * - `resetMs` , ms from now until the limiter is fully replenished for the key.
36 */
37 export interface RateLimitResult {
38 allowed: boolean;
39 remaining: number;
40 limit: number;
41 retryAfterMs: number;
42 resetMs: number;
43 }
44
45 /** Common surface implemented by the simple (single-balance) limiter strategies. */
46 export interface RateLimiter {
47 /**
48 * Attempt to admit `cost` units (default 1) for `key` at the current time.
49 * Quota is consumed only when the request is admitted.
50 */
51 tryAcquire(key: string, cost?: number): RateLimitResult;
52 /** Drop all state for `key` (e.g. on logout). Returns true iff state existed. */
53 reset(key: string): boolean;
54 }
55
56 /**
57 * The outcome returned by {@link TidalRateLimiter.tryAcquire}. Identical to
58 * {@link RateLimitResult} plus one extra advisory field:
59 *
60 * - `debtRemaining` , the whole units of outstanding balance the key currently
61 * owes (rounded up), immediately after this decision; `0` when the key owes
62 * nothing.
63 */
64 export interface TidalResult extends RateLimitResult {
65 debtRemaining: number;
66 }
67/home/user/app/src/limiter/token-bucket.ts
File does not exist. Note: your current working directory is /home/user/app.
ls -R src test 2>/dev/null | head -50 && echo "---" && cat package.json
src:
index.ts
limiter
src/limiter:
clock.ts
errors.ts
slidingWindow.ts
tidal.ts
tokenBucket.ts
types.ts
windowLog.ts
test:
smoke.test.ts
---
{
"name": "rate-limiter",
"version": "0.4.0",
"private": true,
"description": "Rate-limiting toolkit: token-bucket and exact sliding-window-log limiters behind a common interface.",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@types/node": "20.14.10",
"typescript": "5.5.3",
"vitest": "1.6.0"
}
}/home/user/app/src/limiter/tokenBucket.ts
1 import { RateLimitError } from "./errors.js";
2 import { systemClock } from "./clock.js";
3 import type { Clock, RateLimiter, RateLimitResult } from "./types.js";
4
5 export interface TokenBucketOptions {
6 /** Bucket capacity (max burst). Must be a finite number >= 1. */
7 capacity: number;
8 /** Sustained refill rate in tokens per second. Must be finite and > 0. */
9 refillPerSec: number;
10 /** Injectable clock for deterministic tests. Defaults to the system clock. */
11 clock?: Clock;
12 }
13
14 interface Bucket {
15 /** Fractional tokens currently available. */
16 tokens: number;
17 /** Last instant (epoch ms) at which `tokens` was brought up to date. */
18 updatedAt: number;
19 }
20
21 /**
22 * Classic token-bucket limiter: a bucket holds up to `capacity` tokens and
23 * refills continuously at `refillPerSec`. Each admitted request removes `cost`
24 * tokens; a request is refused when fewer than `cost` tokens are available.
25 *
26 * Provided complete and used as the reference sibling for the sliding-window
27 * limiter you are asked to implement , study how it lazily accrues tokens, never
28 * over-fills past capacity, and mutates state only on admission.
29 */
30 export class TokenBucketRateLimiter implements RateLimiter {
31 private readonly capacity: number;
32 private readonly refillPerMs: number;
33 private readonly clock: Clock;
34 private readonly buckets = new Map<string, Bucket>();
35
36 constructor(options: TokenBucketOptions) {
37 if (!Number.isFinite(options.capacity) || options.capacity < 1) {
38 throw new RateLimitError("invalid_capacity", "capacity must be a finite number >= 1");
39 }
40 if (!Number.isFinite(options.refillPerSec) || options.refillPerSec <= 0) {
41 throw new RateLimitError("invalid_refill", "refillPerSec must be a finite number > 0");
42 }
43 this.capacity = options.capacity;
44 this.refillPerMs = options.refillPerSec / 1000;
45 this.clock = options.clock ?? systemClock;
46 }
47
48 tryAcquire(key: string, cost = 1): RateLimitResult {
49 if (!Number.isFinite(cost) || cost < 1) {
50 throw new RateLimitError("invalid_cost", "cost must be a finite number >= 1");
51 }
52 const now = this.clock.now();
53 const bucket = this.refilled(key, now);
54
55 if (bucket.tokens >= cost) {
56 bucket.tokens -= cost;
57 return this.result(true, bucket);
58 }
59 return this.result(false, bucket, cost);
60 }
61
62 reset(key: string): boolean {
63 return this.buckets.delete(key);
64 }
65
66 /** Bring a key's bucket up to date with elapsed refill, creating it if absent. */
67 private refilled(key: string, now: number): Bucket {
68 let bucket = this.buckets.get(key);
69 if (!bucket) {
70 bucket = { tokens: this.capacity, updatedAt: now };
71 this.buckets.set(key, bucket);
72 return bucket;
73 }
74 const elapsed = Math.max(0, now - bucket.updatedAt);
75 if (elapsed > 0) {
76 bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillPerMs);
77 bucket.updatedAt = now;
78 }
79 return bucket;
80 }
81
82 private result(allowed: boolean, bucket: Bucket, cost = 0): RateLimitResult {
83 const remaining = Math.max(0, Math.floor(bucket.tokens));
84 const deficit = allowed ? 0 : cost - bucket.tokens;
85 const retryAfterMs = allowed ? 0 : Math.ceil(deficit / this.refillPerMs);
86 const missingToFull = this.capacity - bucket.tokens;
87 const resetMs = missingToFull <= 0 ? 0 : Math.ceil(missingToFull / this.refillPerMs);
88 return { allowed, remaining, limit: this.capacity, retryAfterMs, resetMs };
89 }
90 }
91/home/user/app/src/limiter/errors.ts
1 /**
2 * Typed errors for the rate-limiter. A single class with a stable `code`
3 * discriminator keeps call sites and tests decoupled from message wording.
4 */
5 export type RateLimitErrorCode =
6 | "invalid_capacity"
7 | "invalid_window"
8 | "invalid_refill"
9 | "invalid_overdraft"
10 | "invalid_repay"
11 | "invalid_burst"
12 | "invalid_cost";
13
14 export class RateLimitError extends Error {
15 readonly code: RateLimitErrorCode;
16
17 constructor(code: RateLimitErrorCode, message: string) {
18 super(message);
19 this.name = "RateLimitError";
20 this.code = code;
21 Object.setPrototypeOf(this, RateLimitError.prototype);
22 }
23 }
24/home/user/app/test/smoke.test.ts
1 import { describe, expect, it } from "vitest";
2 import {
3 TidalRateLimiter,
4 TokenBucketRateLimiter,
5 SlidingWindowRateLimiter,
6 RateLimitError,
7 ManualClock,
8 } from "../src/index.js";
9
10 /**
11 * Trivial smoke test for the shipped project: it only checks that the public
12 * surface is importable and that the provided constructor validation rejects
13 * obviously-bad options. It deliberately does NOT exercise admission behavior ,
14 * the behavioral contract lives in instruction.md and is graded separately.
15 */
16 describe("toolkit smoke", () => {
17 it("exposes the limiter classes", () => {
18 expect(typeof TidalRateLimiter).toBe("function");
19 expect(typeof TokenBucketRateLimiter).toBe("function");
20 expect(typeof SlidingWindowRateLimiter).toBe("function");
21 });
22
23 it("TidalRateLimiter validates its options", () => {
24 const clock = new ManualClock(0);
25 expect(() => new TidalRateLimiter({ capacity: 0, windowMs: 1000, clock })).toThrowError(RateLimitError);
26 expect(() => new TidalRateLimiter({ capacity: 5, windowMs: 0, clock })).toThrowError(RateLimitError);
27 expect(() => new TidalRateLimiter({ capacity: 5, windowMs: 1000, overdraft: 1, clock })).toThrowError(
28 RateLimitError,
29 );
30 expect(() => new TidalRateLimiter({ capacity: 5, windowMs: 1000, repayFraction: 0, clock })).toThrowError(
31 RateLimitError,
32 );
33 // A valid construction does not throw.
34 expect(
35 () => new TidalRateLimiter({ capacity: 5, windowMs: 1000, overdraft: 0.5, repayFraction: 0.5, clock }),
36 ).not.toThrow();
37 });
38
39 it("reset on an unknown key returns false", () => {
40 const clock = new ManualClock(0);
41 const g = new TidalRateLimiter({ capacity: 5, windowMs: 1000, clock });
42 expect(g.reset("never-seen")).toBe(false);
43 });
44 });
45/home/user/app/src/limiter/clock.ts
1 /**
2 * Injectable time source. Tests use {@link ManualClock} so rate-limit windows
3 * advance at exact, controlled instants; production uses {@link systemClock}.
4 *
5 * Provided complete.
6 */
7 import type { Clock } from "./types.js";
8
9 export class ManualClock implements Clock {
10 private current: number;
11
12 constructor(start = 0) {
13 this.current = start;
14 }
15
16 now(): number {
17 return this.current;
18 }
19
20 advance(ms: number): void {
21 if (ms < 0) throw new Error("cannot advance time backwards");
22 this.current += ms;
23 }
24
25 set(ms: number): void {
26 this.current = ms;
27 }
28 }
29
30 export const systemClock: Clock = {
31 now: () => Date.now(),
32 };
33/home/user/app/src/index.ts
1 /**
2 * Public surface of the rate-limiter toolkit.
3 */
4 export { TokenBucketRateLimiter } from "./limiter/tokenBucket.js";
5 export type { TokenBucketOptions } from "./limiter/tokenBucket.js";
6 export { SlidingWindowRateLimiter } from "./limiter/slidingWindow.js";
7 export type { SlidingWindowOptions } from "./limiter/slidingWindow.js";
8 export { EventLog } from "./limiter/windowLog.js";
9
10 // The Tidal credit limiter (the unimplemented core of this exercise).
11 export { TidalRateLimiter } from "./limiter/tidal.js";
12 export type { TidalOptions } from "./limiter/tidal.js";
13
14 export { ManualClock, systemClock } from "./limiter/clock.js";
15 export { RateLimitError } from "./limiter/errors.js";
16 export type { RateLimitErrorCode } from "./limiter/errors.js";
17 export type { Clock, RateLimiter, RateLimitResult, TidalResult } from "./limiter/types.js";
18/home/user/app/src/limiter/tidal.ts
tryAcquire(key: string, cost = 1): TidalResult {
void this.capacity;
void this.windowMs;
void this.overdraft;
void this.repayFraction;
void this.clock;
void this.state;
void this.stateFor;
void this.burstCapacity;
void this.sustainedCapacity;
void this.burstRate;
void this.sustainedRate;
void this.maxDebt;
void RateLimitError;
void key;
void cost;
// TODO(limiter): implement the Tidal credit limiter contract from instruction.md.
throw new Error("TidalRateLimiter.tryAcquire not implemented");
}
} tryAcquire(key: string, cost = 1): TidalResult {
if (!Number.isFinite(cost) || cost < 1) {
throw new RateLimitError("invalid_cost", "cost must be a finite number >= 1");
}
const now = this.clock.now();
const s = this.stateFor(key, now);
this.advance(s, now);
const spendable = s.burst + s.sustained;
let allowed: boolean;
if (spendable + TOL >= cost) {
// Case 1: covered by the spendable balance , spend burst first, then sustained.
allowed = true;
const fromBurst = Math.min(s.burst, cost);
s.burst = clampZero(s.burst - fromBurst);
s.sustained = clampZero(s.sustained - (cost - fromBurst));
} else {
// Case 2/3: must borrow the shortfall against the owed balance.
const shortfall = cost - spendable;
const newDebt = s.debt + shortfall;
if (newDebt <= this.maxDebt + TOL) {
allowed = true;
s.burst = 0;
s.sustained = 0;
s.debt = newDebt;
} else {
allowed = false; // Refusal consumes nothing.
}
}
const remaining = Math.max(0, Math.floor(s.burst + s.sustained + TOL));
const debtRemaining = s.debt <= TOL ? 0 : Math.max(0, Math.ceil(s.debt - TOL));
const resetMs = this.timeToFull(s);
const retryAfterMs = allowed ? 0 : this.timeToAdmit(s, cost);
return {
allowed,
limit: this.capacity,
remaining,
debtRemaining,
retryAfterMs,
resetMs,
};
}
/**
* Bring a key's pools and owed balance up to `now`, applying the continuous,
* concurrent two-pool replenishment (with repayment-while-in-debt) described
* in `instruction.md`.
*/
private advance(s: TidalState, now: number): void {
let dt = Math.max(0, now - s.updatedAt);
s.updatedAt = now;
if (dt <= 0) return;
const Cb = this.burstCapacity;
const Cs = this.sustainedCapacity;
const rb = this.burstRate;
const rs = this.sustainedRate;
const rf = this.repayFraction;
const repayRate = rf * (rb + rs);
if (s.debt > TOL && repayRate > 0) {
// Debt phase: a fixed `repayFraction` share of the combined inflow is
// diverted to the owed balance; each pool nets the rest of its own rate.
const tClear = s.debt / repayRate;
const t1 = Math.min(dt, tClear);
s.burst = Math.min(Cb, s.burst + (1 - rf) * rb * t1);
s.sustained = Math.min(Cs, s.sustained + (1 - rf) * rs * t1);
if (t1 < tClear) {
s.debt = s.debt - repayRate * t1;
dt = 0;
} else {
s.debt = 0;
dt -= tClear;
}
}
if (dt > 0) {
// Owes-nothing phase: each pool accrues at its full rate, capped.
s.debt = 0;
s.burst = Math.min(Cb, s.burst + rb * dt);
s.sustained = Math.min(Cs, s.sustained + rs * dt);
}
}
/**
* Build the piecewise-linear forward trajectory of a key's state, as a list of
* constant-rate segments with breakpoints at debt-clear, burst-full and
* sustained-full. Used to derive the timing fields.
*/
private trajectory(s: TidalState): Segment[] {
const Cb = this.burstCapacity;
const Cs = this.sustainedCapacity;
const rb = this.burstRate;
const rs = this.sustainedRate;
const rf = this.repayFraction;
const repayRate = rf * (rb + rs);
const segs: Segment[] = [];
let t = 0;
let b = s.burst;
let su = s.sustained;
let d = s.debt;
for (let i = 0; i < 8; i++) {
let rbRate: number;
let rsRate: number;
let rdRate: number;
if (d > TOL && repayRate > 0) {
rdRate = -repayRate;
rbRate = b < Cb - TOL ? (1 - rf) * rb : 0;
rsRate = su < Cs - TOL ? (1 - rf) * rs : 0;
} else {
d = 0;
rdRate = 0;
rbRate = b < Cb - TOL ? rb : 0;
rsRate = su < Cs - TOL ? rs : 0;
}
let dur = Infinity;
if (rdRate < 0) dur = Math.min(dur, d / repayRate);
if (rbRate > 0) dur = Math.min(dur, (Cb - b) / rbRate);
if (rsRate > 0) dur = Math.min(dur, (Cs - su) / rsRate);
segs.push({ t0: t, dur, b, s: su, d, rbRate, rsRate, rdRate });
if (!Number.isFinite(dur)) break;
b = Math.min(Cb, b + rbRate * dur);
su = Math.min(Cs, su + rsRate * dur);
d = Math.max(0, d + rdRate * dur);
t += dur;
}
return segs;
}
/** Whole ms until the key is fully replenished: both pools full and no debt. */
private timeToFull(s: TidalState): number {
const segs = this.trajectory(s);
const tBurst = firstReachUp(segs, (seg) => seg.b, (seg) => seg.rbRate, this.burstCapacity);
const tSust = firstReachUp(segs, (seg) => seg.s, (seg) => seg.rsRate, this.sustainedCapacity);
const repayRate = this.repayFraction * (this.burstRate + this.sustainedRate);
const tDebt = s.debt > TOL && repayRate > 0 ? s.debt / repayRate : 0;
const t = Math.max(tBurst, tSust, tDebt);
return Number.isFinite(t) ? Math.max(0, Math.ceil(t - TOL)) : 0;
}
/**
* Smallest whole ms `W` such that retrying the same `cost` after waiting `W`
* (with no intervening traffic) would be admitted , the exact minimum wait
* under the two-tier refill / repay dynamics.
*/
private timeToAdmit(s: TidalState, cost: number): number {
const segs = this.trajectory(s);
// Covered: spendable balance reaches `cost`.
let tCover = Infinity;
for (const seg of segs) {
const v0 = seg.b + seg.s;
if (v0 + TOL >= cost) {
tCover = seg.t0;
break;
}
const r = seg.rbRate + seg.rsRate;
if (r > 0) {
const tt = seg.t0 + (cost - TOL - v0) / r;
if (!Number.isFinite(seg.dur) || tt <= seg.t0 + seg.dur + TOL) {
tCover = tt;
break;
}
}
}
// Borrowable: debt minus spendable falls to within the borrowing headroom.
const target = this.maxDebt - cost + TOL;
let tBorrow = Infinity;
for (const seg of segs) {
const f0 = seg.d - (seg.b + seg.s);
if (f0 <= target) {
tBorrow = seg.t0;
break;
}
const r = seg.rdRate - (seg.rbRate + seg.rsRate);
if (r < 0) {
const tt = seg.t0 + (target - f0) / r;
if (!Number.isFinite(seg.dur) || tt <= seg.t0 + seg.dur + TOL) {
tBorrow = tt;
break;
}
}
}
const t = Math.min(tCover, tBorrow);
if (!Number.isFinite(t)) return this.timeToFull(s); // request unsatisfiable even when full
return Math.max(0, Math.ceil(t - TOL));
}
}
/** Numerical tolerance: balances are exact to within ~a millionth of a unit. */
const TOL = 1e-6;
/** A constant-rate slice of a key's forward trajectory. */
interface Segment {
t0: number;
dur: number;
b: number;
s: number;
d: number;
rbRate: number;
rsRate: number;
rdRate: number;
}
/** Clamp a balance's tiny floating residue to zero. */
function clampZero(x: number): number {
return x > TOL ? x : 0;
}
/** First time (across segments) a monotonically rising value reaches `cap`. */
function firstReachUp(
segs: Segment[],
getVal: (seg: Segment) => number,
getRate: (seg: Segment) => number,
cap: number,
): number {
for (const seg of segs) {
const v0 = getVal(seg);
if (v0 >= cap - TOL) return seg.t0;
const r = getRate(seg);
if (r > 0) {
const tt = seg.t0 + (cap - v0) / r;
if (!Number.isFinite(seg.dur) || tt <= seg.t0 + seg.dur + TOL) return tt;
}
}
return Infinity;
}The file /home/user/app/src/limiter/tidal.ts has been updated successfully. (file state is current in your context , no need to Read it back)
npm run typecheck 2>&1 | tail -20
> rate-limiter@0.4.0 typecheck > tsc -p tsconfig.json --noEmit
npm test 2>&1 | tail -20
> rate-limiter@0.4.0 test
> vitest run
RUN v1.6.0 /home/user/app
✓ test/smoke.test.ts (3 tests) 3ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 20:27:55
Duration 405ms (transform 135ms, setup 0ms, collect 154ms, tests 3ms, environment 0ms, prepare 107ms)cat > /tmp/verify.mjs <<'EOF'
import { TidalRateLimiter, ManualClock } from '/home/user/app/src/index.ts';
EOF
echo "use tsx instead"; ls node_modules/.bin | grep -E 'tsx|ts-node|vitest' use tsx instead vitest
import { describe, it, expect } from "vitest";
import { TidalRateLimiter, ManualClock } from "./src/index.js";
describe("tidal manual checks", () => {
it("default reduces to single-pool token-bucket-like", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, clock });
// start full=10
let r = g.tryAcquire("a", 10);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(0);
expect(r.debtRemaining).toBe(0);
// now empty, refusing cost 1
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(false);
// rate 0.01/ms; need 1 unit -> 100ms
expect(r.retryAfterMs).toBe(100);
// reset: need 10 units at 0.01/ms = 1000ms
expect(r.resetMs).toBe(1000);
clock.advance(100);
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(true);
});
it("overdraft borrowing", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, overdraft: 0.5, clock });
// maxDebt = 5
let r = g.tryAcquire("a", 10); // spend all
expect(r.allowed).toBe(true);
r = g.tryAcquire("a", 5); // borrow 5 -> debt 5 (at limit)
expect(r.allowed).toBe(true);
expect(r.debtRemaining).toBe(5);
expect(r.remaining).toBe(0);
r = g.tryAcquire("a", 1); // would push debt to 6 > 5 -> refuse
expect(r.allowed).toBe(false);
console.log("retryAfter after over-borrow:", r.retryAfterMs, "reset:", r.resetMs);
});
it("two-tier spend burst first", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, burstFraction: 0.3, clock });
// Cb=3, Cs=7, both full -> spendable 10
let r = g.tryAcquire("a", 4); // spend 3 burst + 1 sustained
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(6);
});
it("refusal idempotent", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 5, windowMs: 1000, clock });
g.tryAcquire("a", 5);
const r1 = g.tryAcquire("a", 3);
const r2 = g.tryAcquire("a", 3);
expect(r1).toEqual(r2);
expect(r1.allowed).toBe(false);
});
it("invalid cost throws before state", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 5, windowMs: 1000, clock });
expect(() => g.tryAcquire("a", 0)).toThrow();
expect(() => g.tryAcquire("a", Infinity)).toThrow();
});
});
File created successfully at: /home/user/app/scratch_verify.test.ts (file state is current in your context , no need to Read it back)
npx vitest run scratch_verify.test.ts 2>&1 | tail -30
RUN v1.6.0 /home/user/app
filter: scratch_verify.test.ts
include: test/**/*.test.ts
exclude: **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*
watch exclude: **/node_modules/**, **/dist/**
No test files found, exiting with code 1mv /home/user/app/scratch_verify.test.ts /home/user/app/test/scratch_verify.test.ts && npx vitest run test/scratch_verify.test.ts 2>&1 | tail -30
RUN v1.6.0 /home/user/app
❯ test/scratch_verify.test.ts (0 test)
⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL test/scratch_verify.test.ts [ test/scratch_verify.test.ts ]
Error: Failed to load url ./src/index.js (resolved id: ./src/index.js) in /home/user/app/test/scratch_verify.test.ts. Does the file exist?
❯ loadAndTransform node_modules/vite/dist/node/chunks/dep-BK3b2jBa.js:51969:17
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests no tests
Start at 20:28:26
Duration 273ms (transform 42ms, setup 0ms, collect 0ms, tests 0ms, environment 0ms, prepare 94ms)sed -i 's#./src/index.js#../src/index.js#' test/scratch_verify.test.ts && npx vitest run test/scratch_verify.test.ts 2>&1 | tail -30
RUN v1.6.0 /home/user/app
✓ test/scratch_verify.test.ts (5 tests) 7ms
stdout | test/scratch_verify.test.ts > tidal manual checks > overdraft borrowing
retryAfter after over-borrow: 100 reset: 1500
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 20:28:29
Duration 363ms (transform 119ms, setup 0ms, collect 137ms, tests 7ms, environment 0ms, prepare 98ms)import { describe, it, expect } from "vitest";
import { TidalRateLimiter, ManualClock } from "../src/index.js";
describe("tidal repay/two-window checks", () => {
it("repayFraction < 1 debt + partial pool fill", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4, clock });
g.tryAcquire("a", 10);
let r = g.tryAcquire("a", 5); // debt 5
expect(r.debtRemaining).toBe(5);
r = g.tryAcquire("a", 1); // refuse
expect(r.allowed).toBe(false);
expect(r.retryAfterMs).toBe(100); // borrow path: 5-0.01t<=4
expect(r.resetMs).toBe(1500);
clock.advance(100);
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(true);
expect(r.debtRemaining).toBe(5); // 4.6 + 0.4 = 5 -> ceil 5
});
it("two windows: burst fast, sustained slow", () => {
const clock = new ManualClock(0);
// Cb=2 over 100ms (rb=0.02), Cs=8 over 1000ms (rs=0.008)
const g = new TidalRateLimiter({
capacity: 10,
windowMs: 1000,
burstFraction: 0.2,
burstWindowMs: 100,
sustainedWindowMs: 1000,
clock,
});
let r = g.tryAcquire("a", 10); // empty both
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(0);
// refuse cost 1: cover when burst+sustained>=1
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(false);
// combined rate 0.028/ms -> 1/0.028 = 35.7 -> 36ms
expect(r.retryAfterMs).toBe(36);
// reset: burst full at 100ms, sustained full at 1000ms -> 1000
expect(r.resetMs).toBe(1000);
});
it("fresh key full", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, burstFraction: 0.2, clock });
const r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(9);
expect(r.resetMs).toBe(0); // not full anymore? spent 1 -> need refill
});
});
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
/home/user/app/test/scratch_verify.test.ts
1 import { describe, it, expect } from "vitest";
2 import { TidalRateLimiter, ManualClock } from "../src/index.js";
3import { describe, it, expect } from "vitest";
import { TidalRateLimiter, ManualClock } from "../src/index.js";
describe("tidal repay/two-window checks", () => {
it("repayFraction < 1 debt + partial pool fill", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4, clock });
g.tryAcquire("a", 10);
let r = g.tryAcquire("a", 5); // debt 5
expect(r.debtRemaining).toBe(5);
r = g.tryAcquire("a", 1); // refuse
expect(r.allowed).toBe(false);
expect(r.retryAfterMs).toBe(100); // borrow path: 5-0.01t<=4
expect(r.resetMs).toBe(1500);
clock.advance(100);
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(true);
expect(r.debtRemaining).toBe(5); // 4.6 + 0.4 = 5 -> ceil 5
});
it("two windows: burst fast, sustained slow", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({
capacity: 10,
windowMs: 1000,
burstFraction: 0.2,
burstWindowMs: 100,
sustainedWindowMs: 1000,
clock,
});
let r = g.tryAcquire("a", 10);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(0);
r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(false);
expect(r.retryAfterMs).toBe(36); // combined 0.028/ms -> ceil(35.7)
expect(r.resetMs).toBe(1000);
});
it("fresh key partial spend", () => {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ capacity: 10, windowMs: 1000, burstFraction: 0.2, clock });
const r = g.tryAcquire("a", 1);
expect(r.allowed).toBe(true);
expect(r.remaining).toBe(9);
});
});
The file /home/user/app/test/scratch_verify.test.ts has been updated successfully. (file state is current in your context , no need to Read it back)
npx vitest run test/scratch_verify.test.ts 2>&1 | tail -30
RUN v1.6.0 /home/user/app
✓ test/scratch_verify.test.ts (3 tests) 4ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 20:29:24
Duration 360ms (transform 119ms, setup 0ms, collect 132ms, tests 4ms, environment 0ms, prepare 90ms)import { describe, it, expect } from "vitest";
import { TidalRateLimiter, ManualClock } from "../src/index.js";
// Independent reference: tiny fixed-step Euler integration of the dynamics +
// the admission rules, written from scratch (no shared code with the limiter).
class Ref {
cap: number;
Cb: number;
Cs: number;
rb: number;
rs: number;
rf: number;
maxDebt: number;
b: number;
s: number;
d: number;
t: number;
constructor(o: any) {
this.cap = o.capacity;
const bf = o.burstFraction ?? 1;
this.Cb = o.capacity * bf;
this.Cs = o.capacity * (1 - bf);
const bw = o.burstWindowMs ?? o.windowMs;
const sw = o.sustainedWindowMs ?? o.windowMs;
this.rb = this.Cb / bw;
this.rs = this.Cs > 0 ? this.Cs / sw : 0;
this.rf = o.repayFraction ?? 1;
this.maxDebt = o.capacity * (o.overdraft ?? 0);
this.b = this.Cb;
this.s = this.Cs;
this.d = 0;
this.t = 0;
}
advanceTo(now: number) {
const repayRate = this.rf * (this.rb + this.rs);
let dt = now - this.t;
this.t = now;
const step = 0.05;
while (dt > 1e-12) {
const h = Math.min(step, dt);
if (this.d > 1e-12 && repayRate > 0) {
const dd = Math.min(this.d, repayRate * h);
this.d -= dd;
this.b = Math.min(this.Cb, this.b + (1 - this.rf) * this.rb * h);
this.s = Math.min(this.Cs, this.s + (1 - this.rf) * this.rs * h);
} else {
this.d = 0;
this.b = Math.min(this.Cb, this.b + this.rb * h);
this.s = Math.min(this.Cs, this.s + this.rs * h);
}
dt -= h;
}
}
acquire(now: number, cost: number) {
this.advanceTo(now);
const spend = this.b + this.s;
let allowed: boolean;
if (spend + 1e-6 >= cost) {
allowed = true;
const fb = Math.min(this.b, cost);
this.b = Math.max(0, this.b - fb);
this.s = Math.max(0, this.s - (cost - fb));
} else {
const short = cost - spend;
if (this.d + short <= this.maxDebt + 1e-6) {
allowed = true;
this.b = 0;
this.s = 0;
this.d += short;
} else allowed = false;
}
return {
allowed,
remaining: Math.max(0, Math.floor(this.b + this.s + 1e-6)),
debtRemaining: this.d <= 1e-6 ? 0 : Math.max(0, Math.ceil(this.d - 1e-6)),
};
}
}
function lcg(seed: number) {
let x = seed >>> 0;
return () => {
x = (x * 1664525 + 1013904223) >>> 0;
return x / 0x100000000;
};
}
describe("tidal randomized cross-check vs Euler reference", () => {
it("matches allowed/remaining/debt across random workloads", () => {
const configs = [
{ capacity: 10, windowMs: 1000 },
{ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4 },
{ capacity: 20, windowMs: 2000, burstFraction: 0.3, burstWindowMs: 200, sustainedWindowMs: 2000 },
{ capacity: 12, windowMs: 600, burstFraction: 0.5, overdraft: 0.8, repayFraction: 0.7 },
{ capacity: 8, windowMs: 1000, burstFraction: 0.25, burstWindowMs: 100, sustainedWindowMs: 5000, overdraft: 0.9, repayFraction: 0.3 },
];
for (let c = 0; c < configs.length; c++) {
const rnd = lcg(12345 + c);
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ ...configs[c], clock });
const ref = new Ref(configs[c]);
let now = 0;
for (let i = 0; i < 400; i++) {
now += Math.floor(rnd() * 300);
clock.set(now);
const cost = 1 + Math.floor(rnd() * configs[c].capacity);
const got = g.tryAcquire("k", cost);
const exp = ref.acquire(now, cost);
expect(got.allowed, `cfg${c} i${i} allowed`).toBe(exp.allowed);
// remaining/debt within 1 unit of fine-step reference (Euler granularity)
expect(Math.abs(got.remaining - exp.remaining), `cfg${c} i${i} rem ${got.remaining} vs ${exp.remaining}`).toBeLessThanOrEqual(1);
expect(Math.abs(got.debtRemaining - exp.debtRemaining), `cfg${c} i${i} debt ${got.debtRemaining} vs ${exp.debtRemaining}`).toBeLessThanOrEqual(1);
}
}
});
it("retryAfterMs actually flips refusal to admission at the boundary", () => {
const configs = [
{ capacity: 10, windowMs: 1000 },
{ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4 },
{ capacity: 20, windowMs: 2000, burstFraction: 0.3, burstWindowMs: 200, sustainedWindowMs: 2000 },
{ capacity: 12, windowMs: 600, burstFraction: 0.5, overdraft: 0.8, repayFraction: 0.7 },
];
for (let c = 0; c < configs.length; c++) {
const rnd = lcg(999 + c);
for (let trial = 0; trial < 60; trial++) {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ ...configs[c], clock });
let now = 0;
// drive into some state
for (let i = 0; i < 5; i++) {
now += Math.floor(rnd() * 200);
clock.set(now);
g.tryAcquire("k", 1 + Math.floor(rnd() * configs[c].capacity));
}
const cost = 1 + Math.floor(rnd() * (configs[c].capacity - 1));
const r = g.tryAcquire("k", cost);
if (r.allowed) continue;
const w = r.retryAfterMs;
// waiting w-1 still refuses (if w>=1)
if (w >= 1) {
const g1 = clone(configs[c], "k", now, w - 1, cost, rnd);
// rebuild fresh limiter replaying same history is complex; instead use a
// second limiter sharing the clock by resetting time on same instance:
}
// Use the same instance: advance and retry must be allowed at w.
clock.set(now + w);
const after = g.tryAcquire("k", cost);
expect(after.allowed, `cfg${c} trial${trial} cost${cost} w${w}`).toBe(true);
}
}
});
});
function clone(_o: any, _k: string, _now: number, _w: number, _cost: number, _rnd: () => number) {
return null;
}
The file /home/user/app/test/scratch_verify.test.ts has been updated successfully. (file state is current in your context , no need to Read it back)
/home/user/app/test/scratch_verify.test.ts
it("retryAfterMs actually flips refusal to admission at the boundary", () => {
const configs = [
{ capacity: 10, windowMs: 1000 },
{ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4 },
{ capacity: 20, windowMs: 2000, burstFraction: 0.3, burstWindowMs: 200, sustainedWindowMs: 2000 },
{ capacity: 12, windowMs: 600, burstFraction: 0.5, overdraft: 0.8, repayFraction: 0.7 },
];
for (let c = 0; c < configs.length; c++) {
const rnd = lcg(999 + c);
for (let trial = 0; trial < 60; trial++) {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ ...configs[c], clock });
let now = 0;
// drive into some state
for (let i = 0; i < 5; i++) {
now += Math.floor(rnd() * 200);
clock.set(now);
g.tryAcquire("k", 1 + Math.floor(rnd() * configs[c].capacity));
}
const cost = 1 + Math.floor(rnd() * (configs[c].capacity - 1));
const r = g.tryAcquire("k", cost);
if (r.allowed) continue;
const w = r.retryAfterMs;
// waiting w-1 still refuses (if w>=1)
if (w >= 1) {
const g1 = clone(configs[c], "k", now, w - 1, cost, rnd);
// rebuild fresh limiter replaying same history is complex; instead use a
// second limiter sharing the clock by resetting time on same instance:
}
// Use the same instance: advance and retry must be allowed at w.
clock.set(now + w);
const after = g.tryAcquire("k", cost);
expect(after.allowed, `cfg${c} trial${trial} cost${cost} w${w}`).toBe(true);
}
}
});
});
function clone(_o: any, _k: string, _now: number, _w: number, _cost: number, _rnd: () => number) {
return null;
} it("retryAfterMs: w-1 still refuses, w admits", () => {
const configs = [
{ capacity: 10, windowMs: 1000 },
{ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4 },
{ capacity: 20, windowMs: 2000, burstFraction: 0.3, burstWindowMs: 200, sustainedWindowMs: 2000 },
{ capacity: 12, windowMs: 600, burstFraction: 0.5, overdraft: 0.8, repayFraction: 0.7 },
];
let tested = 0;
for (let c = 0; c < configs.length; c++) {
const rnd = lcg(999 + c);
for (let trial = 0; trial < 80; trial++) {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ ...configs[c], clock });
let now = 0;
for (let i = 0; i < 5; i++) {
now += Math.floor(rnd() * 200);
clock.set(now);
g.tryAcquire("k", 1 + Math.floor(rnd() * configs[c].capacity));
}
const cost = 1 + Math.floor(rnd() * (configs[c].capacity - 1));
const r = g.tryAcquire("k", cost);
if (r.allowed) continue;
tested++;
const w = r.retryAfterMs;
// refusal consumes nothing, so probing w-1 then w on the same instance is valid
if (w >= 1) {
clock.set(now + w - 1);
const before = g.tryAcquire("k", cost);
expect(before.allowed, `cfg${c} trial${trial} cost${cost} w-1=${w - 1} should refuse`).toBe(false);
}
clock.set(now + w);
const after = g.tryAcquire("k", cost);
expect(after.allowed, `cfg${c} trial${trial} cost${cost} w=${w} should admit`).toBe(true);
}
}
expect(tested).toBeGreaterThan(20);
});
});The file /home/user/app/test/scratch_verify.test.ts has been updated successfully. (file state is current in your context , no need to Read it back)
npx vitest run test/scratch_verify.test.ts 2>&1 | tail -40
RUN v1.6.0 /home/user/app
✓ test/scratch_verify.test.ts (2 tests) 205ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 20:30:47
Duration 606ms (transform 141ms, setup 0ms, collect 167ms, tests 205ms, environment 0ms, prepare 94ms)/home/user/app/test/scratch_verify.test.ts
expect(tested).toBeGreaterThan(20); }); });
expect(tested).toBeGreaterThan(20);
});
it("resetMs reaches full replenishment", () => {
const configs = [
{ capacity: 10, windowMs: 1000, overdraft: 0.5, repayFraction: 0.4 },
{ capacity: 20, windowMs: 2000, burstFraction: 0.3, burstWindowMs: 200, sustainedWindowMs: 2000 },
{ capacity: 12, windowMs: 600, burstFraction: 0.5, overdraft: 0.8, repayFraction: 0.7 },
];
for (let c = 0; c < configs.length; c++) {
const rnd = lcg(7 + c);
for (let trial = 0; trial < 40; trial++) {
const clock = new ManualClock(0);
const g = new TidalRateLimiter({ ...configs[c], clock });
let now = 0;
for (let i = 0; i < 6; i++) {
now += Math.floor(rnd() * 200);
clock.set(now);
g.tryAcquire("k", 1 + Math.floor(rnd() * configs[c].capacity));
}
const r = g.tryAcquire("k", 1 + Math.floor(rnd() * configs[c].capacity));
const reset = r.resetMs;
clock.set(now + reset);
// a 0-cost-ish probe: use cost 1 just to read state via a fresh query path;
// instead read by acquiring full capacity which must succeed exactly when full.
const probe = g.tryAcquire("k", configs[c].capacity);
expect(probe.allowed, `cfg${c} trial${trial} reset${reset} full-capacity should admit`).toBe(true);
expect(probe.debtRemaining, `cfg${c} trial${trial} debt at reset`).toBe(0);
}
}
});
});The file /home/user/app/test/scratch_verify.test.ts has been updated successfully. (file state is current in your context , no need to Read it back)
npx vitest run test/scratch_verify.test.ts 2>&1 | tail -20
RUN v1.6.0 /home/user/app
✓ test/scratch_verify.test.ts (3 tests) 218ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 20:31:02
Duration 639ms (transform 150ms, setup 0ms, collect 192ms, tests 218ms, environment 0ms, prepare 88ms)rm -f /home/user/app/test/scratch_verify.test.ts /tmp/verify.mjs && npm run typecheck 2>&1 | tail -5 && echo "=== TEST ===" && npm test 2>&1 | tail -8
> rate-limiter@0.4.0 typecheck
> tsc -p tsconfig.json --noEmit
=== TEST ===
✓ test/smoke.test.ts (3 tests) 4ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 20:31:09
Duration 442ms (transform 145ms, setup 0ms, collect 175ms, tests 4ms, environment 0ms, prepare 116ms)/home/user/app/src/limiter/tidal.ts
142 tryAcquire(key: string, cost = 1): TidalResult {
143 if (!Number.isFinite(cost) || cost < 1) {
144 throw new RateLimitError("invalid_cost", "cost must be a finite number >= 1");
145 }
146
147 const now = this.clock.now();
148 const s = this.stateFor(key, now);
149 this.advance(s, now);
150
151 const spendable = s.burst + s.sustained;
152 let allowed: boolean;
153
154 if (spendable + TOL >= cost) {
155 // Case 1: covered by the spendable balance , spend burst first, then sustained.
156 allowed = true;
157 const fromBurst = Math.min(s.burst, cost);
158 s.burst = clampZero(s.burst - fromBurst);
159 s.sustained = clampZero(s.sustained - (cost - fromBurst));
160 } else {
161 // Case 2/3: must borrow the shortfall against the owed balance.
162 const shortfall = cost - spendable;
163 const newDebt = s.debt + shortfall;
164 if (newDebt <= this.maxDebt + TOL) {
165 allowed = true;
166 s.burst = 0;
167 s.sustained = 0;
168 s.debt = newDebt;
169 } else {
170 allowed = false; // Refusal consumes nothing.
171 }
172 }
173
174 const remaining = Math.max(0, Math.floor(s.burst + s.sustained + TOL));
175 const debtRemaining = s.debt <= TOL ? 0 : Math.max(0, Math.ceil(s.debt - TOL));
176 const resetMs = this.timeToFull(s);
177 const retryAfterMs = allowed ? 0 : this.timeToAdmit(s, cost);
178
179 return {
180 allowed,
181 limit: this.capacity,
182 remaining,
183 debtRemaining,
184 retryAfterMs,
185 resetMs,
186 };
187 }
188
189 /**
190 * Bring a key's pools and owed balance up to `now`, applying the continuous,
191 * concurrent two-pool replenishment (with repayment-while-in-debt) described
192 * in `instruction.md`.
193 */
194 private advance(s: TidalState, now: number): void {
195 let dt = Math.max(0, now - s.updatedAt);
196 s.updatedAt = now;
197 if (dt <= 0) return;
198
199 const Cb = this.burstCapacity;
200 const Cs = this.sustainedCapacity;
201 const rb = this.burstRate;No file changes, the agent left the workspace untouched.
=== staging hidden tests from /home/user/tests into /home/user/app/test ===
=== running test suite in /home/user/app ===
RUN v1.6.0 /home/user/app
✓ test/tidal.hidden.test.ts > TidalRateLimiter , construction & validation > rejects bad capacity / window / overdraft / repayFraction / burstFraction
✓ test/tidal.hidden.test.ts > TidalRateLimiter , construction & validation > validates cost before touching state and reports the right code
✓ test/tidal.hidden.test.ts > TidalRateLimiter , single-tier back-compat (burstFraction defaults to 1) > default options behave like the original continuous bucket
✓ test/tidal.hidden.test.ts > TidalRateLimiter , single-tier back-compat (burstFraction defaults to 1) > burstFraction=1 matches the single-tier oracle exactly under a mixed workload
✓ test/tidal.hidden.test.ts > TidalRateLimiter , two-tier pools (burst spent before sustained) > a fresh key starts with both pools full and spends burst first
✓ test/tidal.hidden.test.ts > TidalRateLimiter , two-tier pools (burst spent before sustained) > burst and sustained refill concurrently at different rates (kink at burst saturation)
✓ test/tidal.hidden.test.ts > TidalRateLimiter , borrowing across both pools > a borrow drives both pools to zero and owes the shortfall
✓ test/tidal.hidden.test.ts > TidalRateLimiter , borrowing across both pools > refuses a borrow past the overdraft limit without consuming, then admits a fitting one
✓ test/tidal.hidden.test.ts > TidalRateLimiter , borrowing across both pools > a single request larger than capacity+maxDebt can never be admitted from full
✓ test/tidal.hidden.test.ts > TidalRateLimiter , repay-first split with two pools (exact vs oracle) > while owing, only (1-repayFraction) of each tier's inflow reaches its pool
✓ test/tidal.hidden.test.ts > TidalRateLimiter , repay-first split with two pools (exact vs oracle) > debt clearing mid-interval bumps the pool refill rate (kink) , matches oracle
✓ test/tidal.hidden.test.ts > TidalRateLimiter , refusal idempotency > two refusals at the same instant are identical and consume nothing
✓ test/tidal.hidden.test.ts > TidalRateLimiter , keys, reset, cost weighting > isolates keys and supports reset
✓ test/tidal.hidden.test.ts > TidalRateLimiter , resetMs reaches zero only at full replenishment (oracle-pinned) > matches the oracle's resetMs and a full spend succeeds right at it
✓ test/tidal.hidden.test.ts > TidalRateLimiter , retryAfterMs is exact across refill kinks (oracle-grounded) > waiting retryAfterMs admits; waiting less refuses; value equals the oracle
× test/tidal.hidden.test.ts > TidalRateLimiter , randomized cross-check against the independent oracle > matches admissions, balances and BOTH timing fields over long mixed workloads
→ expected 1108 to be 1109 // Object.is equality
× test/tidal.hidden.test.ts > TidalRateLimiter , randomized cross-check against the independent oracle > matches under heavy borrowing churn near the overdraft edge (debt-biased steps)
→ expected 537 to be 538 // Object.is equality
✓ test/tidal.hidden.test.ts > TidalRateLimiter , guards against standard / single-tier implementations > a sliding-window-log limiter (no debt) diverges on the borrow path
✓ test/tidal.hidden.test.ts > TidalRateLimiter , guards against standard / single-tier implementations > the two-tier refill is NOT a single-tier bucket: tier windows change retryAfterMs
✓ test/tidal.hidden.test.ts > TidalRateLimiter , guards against standard / single-tier implementations > respects the structural bounds and the borrow rule
✓ test/smoke.test.ts > toolkit smoke > exposes the limiter classes
✓ test/smoke.test.ts > toolkit smoke > TidalRateLimiter validates its options
✓ test/smoke.test.ts > toolkit smoke > reset on an unknown key returns false
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL test/tidal.hidden.test.ts > TidalRateLimiter , randomized cross-check against the independent oracle > matches admissions, balances and BOTH timing fields over long mixed workloads
AssertionError: expected 1108 to be 1109 // Object.is equality
- Expected
+ Received
- 1109
+ 1108
❯ test/tidal.hidden.test.ts:341:34
339| expect(nearUnits(got.debtRemaining, exp.debtRemaining)).toBe(t…
340| expect(got.limit).toBe(capacity);
341| expect(got.retryAfterMs).toBe(exp.retryAfterMs); // exact, ora…
| ^
342| expect(got.resetMs).toBe(exp.resetMs); // exact, oracle-ground…
343| now += Math.floor(rnd() * (windowMs / 2));
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
FAIL test/tidal.hidden.test.ts > TidalRateLimiter , randomized cross-check against the independent oracle > matches under heavy borrowing churn near the overdraft edge (debt-biased steps)
AssertionError: expected 537 to be 538 // Object.is equality
- Expected
+ Received
- 538
+ 537
❯ test/tidal.hidden.test.ts:372:34
370| expect(nearUnits(got.remaining, exp.remaining)).toBe(true);
371| expect(nearUnits(got.debtRemaining, exp.debtRemaining)).toBe(t…
372| expect(got.retryAfterMs).toBe(exp.retryAfterMs);
| ^
373| expect(got.resetMs).toBe(exp.resetMs);
374| now += Math.floor(rnd() * (windowMs / 6));
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯
Test Files 1 failed | 1 passed (2)
Tests 2 failed | 21 passed (23)
Start at 20:31:39
Duration 1.20s (transform 207ms, setup 0ms, collect 275ms, tests 487ms, environment 0ms, prepare 178ms)
=== vitest exit status: 1 ===
RESULT: FAIL (reward=0)Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_5accb8a047314d85. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_5accb8a047314d85 · verifier authoritative; classifier explanatory.