diff --git a/data_structures/deno.json b/data_structures/deno.json index 2b89f1572023..da895e9caadc 100644 --- a/data_structures/deno.json +++ b/data_structures/deno.json @@ -10,6 +10,7 @@ "./comparators": "./comparators.ts", "./red-black-tree": "./red_black_tree.ts", "./unstable-2d-array": "./unstable_2d_array.ts", + "./unstable-rolling-counter": "./unstable_rolling_counter.ts", "./unstable-deque": "./unstable_deque.ts" } } diff --git a/data_structures/unstable_rolling_counter.ts b/data_structures/unstable_rolling_counter.ts new file mode 100644 index 000000000000..87a47711fe15 --- /dev/null +++ b/data_structures/unstable_rolling_counter.ts @@ -0,0 +1,232 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * A fixed-size rolling counter. + * + * The counter splits a window into a fixed number of segments, each tracking + * a count. It has no built-in clock. Call + * {@linkcode RollingCounter.prototype.rotate | `rotate`} to advance the + * window on your own schedule. + * + * The class is iterable and yields segment counts from oldest to newest. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * // 3 segments, all starting at zero + * const counter = new RollingCounter(3); + * + * // Add 5 to the current (newest) segment + * counter.increment(5); + * assertEquals(counter.total, 5); + * assertEquals([...counter], [0, 0, 5]); + * + * // Advance the window, then add 3 to the new segment + * counter.rotate(); + * counter.increment(3); + * assertEquals(counter.total, 8); + * assertEquals([...counter], [0, 5, 3]); + * + * // Bulk-rotate 2 steps: evicts the two oldest (0 and 5) + * counter.rotate(2); + * assertEquals(counter.total, 3); + * assertEquals([...counter], [3, 0, 0]); + * ``` + */ +export class RollingCounter { + #segments: number[]; + #cursor: number; + #total: number; + + /** + * Creates a counter with the given number of segments, all starting at zero. + * + * @param segmentCount The number of segments. Must be a positive integer. + */ + constructor(segmentCount: number) { + if ( + !Number.isInteger(segmentCount) || segmentCount < 1 + ) { + throw new RangeError( + `Cannot create RollingCounter: segmentCount must be a positive integer, got ${segmentCount}`, + ); + } + this.#segments = new Array(segmentCount).fill(0); + this.#cursor = segmentCount - 1; + this.#total = 0; + } + + /** + * Adds `n` to the current segment. + * + * @param n The amount to add. Defaults to `1`. Must be a non-negative integer. + * @returns The new total across all segments. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * const total = counter.increment(5); + * assertEquals(total, 5); + * ``` + */ + increment(n: number = 1): number { + if (!Number.isInteger(n) || n < 0) { + throw new RangeError( + `Cannot increment RollingCounter: n must be a non-negative integer, got ${n}`, + ); + } + this.#segments[this.#cursor]! += n; + this.#total += n; + return this.#total; + } + + /** + * Advances the window by `steps`, dropping the oldest segments. If `steps` + * is at least {@linkcode segmentCount}, all segments are cleared. + * + * @param steps How many steps to advance. Defaults to `1`. Must be a + * non-negative integer. + * @returns The sum of the counts that were removed. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(2); + * counter.increment(10); + * counter.rotate(); + * const evicted = counter.rotate(); + * assertEquals(evicted, 10); + * ``` + * + * @example Bulk rotation + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(5); + * counter.rotate(); + * counter.increment(3); + * + * const evicted = counter.rotate(2); + * assertEquals(evicted, 5); + * assertEquals(counter.total, 3); + * ``` + */ + rotate(steps: number = 1): number { + if (!Number.isInteger(steps) || steps < 0) { + throw new RangeError( + `Cannot rotate RollingCounter: steps must be a non-negative integer, got ${steps}`, + ); + } + const len = this.#segments.length; + if (steps >= len) { + const evicted = this.#total; + this.#segments.fill(0); + this.#cursor = (this.#cursor + steps) % len; + this.#total = 0; + return evicted; + } + let evicted = 0; + for (let i = 0; i < steps; i++) { + this.#cursor = (this.#cursor + 1) % len; + evicted += this.#segments[this.#cursor]!; + this.#segments[this.#cursor] = 0; + } + this.#total -= evicted; + return evicted; + } + + /** + * The sum of all segment counts. + * + * @returns The sum of all segment counts. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(5); + * counter.rotate(); + * counter.increment(3); + * assertEquals(counter.total, 8); + * ``` + */ + get total(): number { + return this.#total; + } + + /** + * The number of segments in the window. + * + * @returns The number of segments. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(5); + * assertEquals(counter.segmentCount, 5); + * ``` + */ + get segmentCount(): number { + return this.#segments.length; + } + + /** + * Resets all segments to zero, as if the counter were just created. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(10); + * counter.clear(); + * assertEquals(counter.total, 0); + * ``` + */ + clear(): void { + this.#segments.fill(0); + this.#cursor = this.#segments.length - 1; + this.#total = 0; + } + + /** + * Yields segment counts from oldest to newest. + * + * @returns An iterator over segment counts. + * + * @example Usage + * ```ts + * import { RollingCounter } from "@std/data-structures/unstable-rolling-counter"; + * import { assertEquals } from "@std/assert"; + * + * const counter = new RollingCounter(3); + * counter.increment(5); + * counter.rotate(); + * counter.increment(3); + * assertEquals([...counter], [0, 5, 3]); + * ``` + */ + *[Symbol.iterator](): IterableIterator { + const len = this.#segments.length; + for (let i = 1; i <= len; i++) { + yield this.#segments[(this.#cursor + i) % len]!; + } + } +} diff --git a/data_structures/unstable_rolling_counter_test.ts b/data_structures/unstable_rolling_counter_test.ts new file mode 100644 index 000000000000..602b2058a5dc --- /dev/null +++ b/data_structures/unstable_rolling_counter_test.ts @@ -0,0 +1,204 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertThrows } from "@std/assert"; +import { RollingCounter } from "./unstable_rolling_counter.ts"; + +// -- Constructor -- + +Deno.test("RollingCounter() throws on invalid segmentCount", () => { + for (const bad of [0, -1, 1.5, NaN, Infinity]) { + assertThrows(() => new RollingCounter(bad), RangeError); + } +}); + +Deno.test("RollingCounter() initializes with correct segmentCount and zero total", () => { + const counter = new RollingCounter(3); + assertEquals(counter.segmentCount, 3); + assertEquals(counter.total, 0); + assertEquals([...counter], [0, 0, 0]); +}); + +// -- increment -- + +Deno.test("RollingCounter.increment() throws on invalid n", () => { + const counter = new RollingCounter(3); + for (const bad of [-1, 1.5, NaN, Infinity]) { + assertThrows(() => counter.increment(bad), RangeError); + } +}); + +Deno.test("RollingCounter.increment() defaults to 1 and accumulates in current segment", () => { + const counter = new RollingCounter(3); + assertEquals(counter.increment(), 1); + assertEquals(counter.increment(4), 5); + assertEquals(counter.increment(0), 5); + assertEquals([...counter], [0, 0, 5]); +}); + +// -- rotate (single step) -- + +Deno.test("RollingCounter.rotate() evicts oldest segment and updates total", () => { + const counter = new RollingCounter(3); + counter.increment(5); + counter.rotate(); + counter.increment(3); + counter.rotate(); + counter.increment(7); + + assertEquals([...counter], [5, 3, 7]); + assertEquals(counter.total, 15); + + const evicted = counter.rotate(); + assertEquals(evicted, 5); + assertEquals(counter.total, 10); + assertEquals([...counter], [3, 7, 0]); +}); + +Deno.test("RollingCounter.rotate() on empty segments returns 0", () => { + const counter = new RollingCounter(3); + assertEquals(counter.rotate(), 0); + assertEquals(counter.total, 0); +}); + +// -- rotate(steps) -- + +Deno.test("RollingCounter.rotate() throws on invalid steps", () => { + const counter = new RollingCounter(3); + for (const bad of [-1, 1.5, NaN, Infinity]) { + assertThrows(() => counter.rotate(bad), RangeError); + } +}); + +Deno.test("RollingCounter.rotate() with steps=0 is a no-op", () => { + const counter = new RollingCounter(3); + counter.increment(10); + assertEquals(counter.rotate(0), 0); + assertEquals(counter.total, 10); + assertEquals([...counter], [0, 0, 10]); +}); + +Deno.test("RollingCounter.rotate() bulk advances partial window", () => { + const counter = new RollingCounter(4); + counter.increment(10); + counter.rotate(); + counter.increment(20); + counter.rotate(); + counter.increment(30); + counter.rotate(); + counter.increment(40); + + const evicted = counter.rotate(2); + assertEquals(evicted, 30); + assertEquals(counter.total, 70); + assertEquals([...counter], [30, 40, 0, 0]); +}); + +Deno.test("RollingCounter.rotate() with steps >= segmentCount clears all and positions cursor correctly", () => { + const counter = new RollingCounter(3); + counter.increment(5); + counter.rotate(); + counter.increment(3); + + const evicted = counter.rotate(3); + assertEquals(evicted, 8); + assertEquals(counter.total, 0); + + counter.increment(1); + counter.rotate(); + counter.increment(2); + assertEquals([...counter], [0, 1, 2]); +}); + +Deno.test("RollingCounter.rotate() with steps > segmentCount clears all", () => { + const counter = new RollingCounter(3); + counter.increment(5); + counter.rotate(); + counter.increment(3); + + const evicted = counter.rotate(100); + assertEquals(evicted, 8); + assertEquals(counter.total, 0); +}); + +Deno.test("RollingCounter.rotate() bulk matches repeated single rotates", () => { + const a = new RollingCounter(4); + const b = new RollingCounter(4); + for (const c of [a, b]) { + c.increment(10); + c.rotate(); + c.increment(20); + c.rotate(); + c.increment(30); + c.rotate(); + c.increment(40); + } + + let evictedA = 0; + for (let i = 0; i < 3; i++) evictedA += a.rotate(); + const evictedB = b.rotate(3); + + assertEquals(evictedA, evictedB); + assertEquals(a.total, b.total); + assertEquals([...a], [...b]); +}); + +// -- clear -- + +Deno.test("RollingCounter.clear() resets to initial state", () => { + const counter = new RollingCounter(3); + counter.increment(10); + counter.rotate(); + counter.increment(5); + counter.rotate(); + counter.clear(); + + const fresh = new RollingCounter(3); + assertEquals(counter.total, fresh.total); + assertEquals(counter.segmentCount, fresh.segmentCount); + assertEquals([...counter], [...fresh]); + + counter.increment(7); + counter.rotate(); + counter.increment(2); + fresh.increment(7); + fresh.rotate(); + fresh.increment(2); + assertEquals([...counter], [...fresh]); +}); + +// -- Symbol.iterator -- + +Deno.test("RollingCounter[Symbol.iterator]() yields segments oldest to newest", () => { + const counter = new RollingCounter(3); + counter.increment(1); + counter.rotate(); + counter.increment(2); + counter.rotate(); + counter.increment(3); + assertEquals([...counter], [1, 2, 3]); + + counter.rotate(); + assertEquals([...counter], [2, 3, 0]); +}); + +// -- Edge cases -- + +Deno.test("RollingCounter with segmentCount of 1 evicts on every rotate", () => { + const counter = new RollingCounter(1); + counter.increment(42); + assertEquals([...counter], [42]); + + const evicted = counter.rotate(); + assertEquals(evicted, 42); + assertEquals(counter.total, 0); + assertEquals([...counter], [0]); +}); + +Deno.test("RollingCounter handles many rotations without data loss", () => { + const counter = new RollingCounter(3); + counter.increment(10); + for (let i = 0; i < 100; i++) counter.rotate(); + assertEquals(counter.total, 0); + + counter.increment(1); + assertEquals([...counter], [0, 0, 1]); +});