Element-wise conditional selection: seriesWhere / seriesMask and dataFrameWhere / dataFrameMask. Accepts boolean arrays, label-aligned boolean Series/DataFrame, or callables. Mirrors pandas.Series.where, pandas.DataFrame.where, and their .mask() inverses.
diff --git a/playground/series-map.html b/playground/series-map.html
new file mode 100644
index 00000000..171809b1
--- /dev/null
+++ b/playground/series-map.html
@@ -0,0 +1,281 @@
+
+
+
+
+
+
tsb — Series.map()
+
+
+
+
+
+
+
Initializing playground…
+
+
+
← Back to roadmap
+
Series.map()
+
Map values using a function, Record, Series, or ES6 Map — mirrors pandas.Series.map.
+
+
+
1 — Function mapper
+
Apply a function (value, index, pos) => U to every element.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
2 — Record / dict mapper
+
Look up each value (stringified) in a plain JS object. Missing keys produce null.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
3 — Series mapper
+
Look up each value by label in another Series. Missing labels produce null.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
4 — ES6 Map mapper
+
Use a native Map<Scalar, U> for non-string keys (numbers, booleans, null, etc.).
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
5 — naAction: "ignore"
+
Pass { naAction: "ignore" } to preserve existing null/NaN values without looking them up.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
+
+
diff --git a/playground/window_indexers.html b/playground/window_indexers.html
new file mode 100644
index 00000000..61aa43f5
--- /dev/null
+++ b/playground/window_indexers.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+
tsb — Window Indexers
+
+
+
+
+
+
+
Initializing playground…
+
+
+
← Back to roadmap
+
🪟 Window Indexers
+
Custom window indexers let you define arbitrary window shapes for rolling computations — mirrors pandas.api.indexers .
+
+
+
1 — FixedForwardWindowIndexer
+
The default rolling looks backward . FixedForwardWindowIndexer looks forward — each row's window covers the next N rows.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
2 — VariableOffsetWindowIndexer
+
Define a different look-back (or look-forward) depth for each row. Useful for event-driven windows or irregular-frequency time series.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
3 — Custom BaseIndexer subclass
+
Subclass BaseIndexer to implement any window shape you need.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
+
+
diff --git a/src/core/frame.ts b/src/core/frame.ts
index ddb641a1..ec18d144 100644
--- a/src/core/frame.ts
+++ b/src/core/frame.ts
@@ -19,12 +19,12 @@
import { DataFrameGroupBy } from "../groupby/index.ts";
import type { Label, Scalar } from "../types.ts";
-import { EWM } from "../window/ewm.ts";
-import type { EwmOptions } from "../window/ewm.ts";
-import { Expanding } from "../window/expanding.ts";
-import type { ExpandingOptions } from "../window/expanding.ts";
-import { Rolling } from "../window/rolling.ts";
-import type { RollingOptions } from "../window/rolling.ts";
+import { EWM } from "../window/index.ts";
+import type { EwmOptions } from "../window/index.ts";
+import { Expanding } from "../window/index.ts";
+import type { ExpandingOptions } from "../window/index.ts";
+import { Rolling } from "../window/index.ts";
+import type { RollingOptions } from "../window/index.ts";
import { Index } from "./base-index.ts";
import { RangeIndex } from "./range-index.ts";
import { Series } from "./series.ts";
@@ -581,6 +581,43 @@ export class DataFrame {
yield* this._columns.entries();
}
+ /**
+ * Iterate over DataFrame rows as objects with an `Index` property plus
+ * one property per column — mirrors `DataFrame.itertuples()`.
+ *
+ * Unlike pandas' named-tuples (which are positional), JavaScript does not
+ * have positional tuples with named fields, so each row is returned as a
+ * plain object `{ Index: label, col1: val, col2: val, ... }`. The `Index`
+ * key matches the pandas `itertuples(name="Pandas")` default.
+ *
+ * @param index - Whether to include the row index as the `Index` field.
+ * Default `true`.
+ * @param name - Unused (kept for API parity); TypeScript records have no
+ * class name.
+ *
+ * @example
+ * ```ts
+ * const df = new DataFrame({ a: [1, 2], b: ["x", "y"] });
+ * for (const row of df.itertuples()) {
+ * console.log(row); // { Index: 0, a: 1, b: "x" } then { Index: 1, a: 2, b: "y" }
+ * }
+ * ```
+ */
+ *itertuples(index = true, _name?: string): IterableIterator
> {
+ const nRows = this.index.size;
+ const colNames = this.columns.values as readonly string[];
+ for (let i = 0; i < nRows; i++) {
+ const row: Record = {};
+ if (index) {
+ row["Index"] = this.index.at(i) as Scalar;
+ }
+ for (const name of colNames) {
+ row[name] = this.col(name).iat(i);
+ }
+ yield row;
+ }
+ }
+
/** Iterate over `(rowLabel, rowSeries)` pairs — mirrors `DataFrame.iterrows()`. */
*iterrows(): IterableIterator<[Label, Series]> {
const nRows = this.index.size;
diff --git a/src/core/series.ts b/src/core/series.ts
index 2410e13c..59301f20 100644
--- a/src/core/series.ts
+++ b/src/core/series.ts
@@ -1049,11 +1049,109 @@ export class Series {
}
/**
- * Apply a function to each value, returning a new Series.
+ * Apply a mapper to each value, returning a new Series.
+ *
+ * Three forms are supported (mirroring `pandas.Series.map`):
+ *
+ * 1. **Function** `(value, index, pos) => U` — called for every element.
+ * 2. **Plain object / Record** `{ [key: string]: U }` — each value is looked
+ * up by its string representation; missing keys map to `null`.
+ * 3. **Series mapper** — each value is looked up in the mapper Series by
+ * index label; missing labels map to `null`.
+ * 4. **ES6 Map** — direct `Map` lookup; missing keys map to `null`.
+ *
+ * When `naAction` is `"ignore"`, existing `null`/`undefined`/`NaN` values
+ * are passed through unchanged instead of being looked up in the mapper.
+ * The option is only meaningful for dict/Series/Map forms.
+ *
+ * @example
+ * ```ts
+ * import { Series } from "tsb";
+ * const s = new Series({ data: [1, 2, 3], name: "x" });
+ * s.map(v => v * 10); // [10, 20, 30]
+ * s.map({ "1": "one", "2": "two" }); // ["one", "two", null]
+ * ```
*/
- map(fn: (value: T, index: Label, pos: number) => U): Series {
- return new Series({
- data: this._values.map((v, i) => fn(v, this.index.at(i), i)),
+ map(fn: (value: T, index: Label, pos: number) => U): Series;
+ map(
+ mapper: Record,
+ options?: { naAction?: "ignore" | null },
+ ): Series;
+ map(
+ mapper: Series,
+ options?: { naAction?: "ignore" | null },
+ ): Series;
+ map(
+ mapper: Map,
+ options?: { naAction?: "ignore" | null },
+ ): Series;
+ map(
+ mapperOrFn:
+ | ((value: T, index: Label, pos: number) => U)
+ | Record
+ | Series
+ | Map,
+ options?: { naAction?: "ignore" | null },
+ ): Series | Series {
+ const naAction = options?.naAction ?? null;
+
+ if (typeof mapperOrFn === "function") {
+ return new Series({
+ data: this._values.map((v, i) => (mapperOrFn as (value: T, index: Label, pos: number) => U)(v, this.index.at(i), i)),
+ index: this.index,
+ name: this.name,
+ });
+ }
+
+ // Helper: is a value "NA" for the purposes of naAction
+ const isNa = (v: Scalar): boolean =>
+ v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
+
+ if (mapperOrFn instanceof Map) {
+ const m = mapperOrFn as Map;
+ const data = this._values.map((v): U | null => {
+ if (naAction === "ignore" && isNa(v)) {
+ return v as unknown as U | null;
+ }
+ const result = m.get(v);
+ return result !== undefined ? result : null;
+ });
+ return new Series({
+ data,
+ index: this.index,
+ name: this.name,
+ });
+ }
+
+ if (mapperOrFn instanceof Series) {
+ const seriesMapper = mapperOrFn as Series;
+ const data = this._values.map((v): U | null => {
+ if (naAction === "ignore" && isNa(v)) {
+ return v as unknown as U | null;
+ }
+ if (!seriesMapper.index.contains(v as Label)) {
+ return null;
+ }
+ return seriesMapper.loc(v as Label) as U | null;
+ });
+ return new Series({
+ data,
+ index: this.index,
+ name: this.name,
+ });
+ }
+
+ // Plain object / Record
+ const rec = mapperOrFn as Record;
+ const data = this._values.map((v): U | null => {
+ if (naAction === "ignore" && isNa(v)) {
+ return v as unknown as U | null;
+ }
+ const key = String(v);
+ return Object.prototype.hasOwnProperty.call(rec, key) ? (rec[key] as U) : null;
+ });
+ return new Series({
+ data,
index: this.index,
name: this.name,
});
@@ -1180,6 +1278,34 @@ export class Series {
groupby(by: readonly Scalar[] | Series): SeriesGroupBy {
return new SeriesGroupBy(this as Series, by);
}
+
+ // ─── items / iteritems ────────────────────────────────────────────────────
+
+ /**
+ * Lazily iterate over `(label, value)` pairs — mirrors `Series.items()`.
+ *
+ * @example
+ * ```ts
+ * const s = new Series({ data: [10, 20], index: ["a", "b"] });
+ * for (const [label, value] of s.items()) {
+ * console.log(label, value); // "a" 10 then "b" 20
+ * }
+ * ```
+ */
+ *items(): IterableIterator<[Label, T]> {
+ const n = this.index.size;
+ for (let i = 0; i < n; i++) {
+ yield [this.index.at(i) as Label, this.iat(i) as T];
+ }
+ }
+
+ /**
+ * Alias for `items()` — mirrors `Series.iteritems()` (deprecated in pandas
+ * 1.5 but still widely used).
+ */
+ *iteritems(): IterableIterator<[Label, T]> {
+ yield* this.items();
+ }
}
function isIndexLike(v: unknown): v is Index {
diff --git a/src/groupby/grouper.ts b/src/groupby/grouper.ts
new file mode 100644
index 00000000..fbeff15a
--- /dev/null
+++ b/src/groupby/grouper.ts
@@ -0,0 +1,181 @@
+/**
+ * Grouper — a specification object for groupby operations.
+ *
+ * Mirrors `pandas.Grouper` — a convenience class that encapsulates grouping
+ * parameters so they can be passed as a reusable spec to `groupby()`.
+ *
+ * @example
+ * ```ts
+ * import { Grouper, DataFrame } from "tsb";
+ *
+ * const df = DataFrame.fromColumns({
+ * date: ["2021-01", "2021-01", "2021-02"],
+ * val: [1, 2, 3],
+ * });
+ *
+ * // Group by the "date" column
+ * const g = new Grouper({ key: "date" });
+ * df.groupby(g).sum();
+ * ```
+ */
+
+import type { Label } from "../types.ts";
+
+// ─── options ──────────────────────────────────────────────────────────────────
+
+/** Options accepted by the {@link Grouper} constructor. */
+export interface GrouperOptions {
+ /**
+ * The column name (or index level name) to group by.
+ * Mirrors the `key` parameter of `pd.Grouper`.
+ */
+ key?: string;
+
+ /**
+ * Frequency string for time-based resampling (e.g. `"1D"`, `"ME"`, `"QS"`).
+ * When set, the Grouper represents a resampling operation on the `key` column.
+ * Mirrors the `freq` parameter of `pd.Grouper`.
+ */
+ freq?: string;
+
+ /**
+ * The axis along which the grouper operates (0 = rows, 1 = columns).
+ * Defaults to `0`. Mirrors `pd.Grouper(axis=...)`.
+ * @deprecated pandas ≥ 2.0 removed the axis parameter.
+ */
+ axis?: 0 | 1;
+
+ /**
+ * Sort the group keys.
+ * Defaults to `false`. Mirrors `pd.Grouper(sort=...)`.
+ */
+ sort?: boolean;
+
+ /**
+ * Drop NA group keys when `True`.
+ * Defaults to `true`. Mirrors `pd.Grouper(dropna=...)`.
+ */
+ dropna?: boolean;
+
+ /**
+ * The index level (by name or integer position) to group on.
+ * Mirrors `pd.Grouper(level=...)`.
+ */
+ level?: Label | number;
+
+ /**
+ * Closed side of the interval for time-based grouping.
+ * One of `"left"` or `"right"`. Mirrors `pd.Grouper(closed=...)`.
+ */
+ closed?: "left" | "right";
+
+ /**
+ * Which end of the interval the label corresponds to.
+ * One of `"left"` or `"right"`. Mirrors `pd.Grouper(label=...)`.
+ */
+ label?: "left" | "right";
+}
+
+// ─── Grouper ──────────────────────────────────────────────────────────────────
+
+/**
+ * A specification object that encapsulates groupby parameters.
+ *
+ * Mirrors `pandas.Grouper`. Pass an instance to `groupby()` instead of a raw
+ * column name when you want to reuse grouping specs or use advanced options
+ * such as `freq`, `level`, `sort`, or `dropna`.
+ *
+ * @example
+ * ```ts
+ * // Group by a column with explicit sort
+ * const g = new Grouper({ key: "dept", sort: true });
+ * df.groupby(g).mean();
+ *
+ * // Group by index level
+ * const gl = new Grouper({ level: 0 });
+ * df.groupby(gl).sum();
+ * ```
+ */
+export class Grouper {
+ /** Column / index level name. */
+ readonly key: string | undefined;
+
+ /** Frequency string for time-based grouping. */
+ readonly freq: string | undefined;
+
+ /** Axis (0 = rows, 1 = columns). */
+ readonly axis: 0 | 1;
+
+ /** Whether to sort group keys. */
+ readonly sort: boolean;
+
+ /** Whether to drop NA group keys. */
+ readonly dropna: boolean;
+
+ /** Index level to group on. */
+ readonly level: Label | number | undefined;
+
+ /** Closed side for interval-based grouping. */
+ readonly closed: "left" | "right" | undefined;
+
+ /** Label side for interval-based grouping. */
+ readonly label: "left" | "right" | undefined;
+
+ constructor(options: GrouperOptions = {}) {
+ this.key = options.key;
+ this.freq = options.freq;
+ this.axis = options.axis ?? 0;
+ this.sort = options.sort ?? false;
+ this.dropna = options.dropna ?? true;
+ this.level = options.level;
+ this.closed = options.closed;
+ this.label = options.label;
+ }
+
+ /**
+ * Returns `true` if this Grouper represents a frequency-based (time) grouping.
+ */
+ isFreqGrouper(): boolean {
+ return this.freq !== undefined;
+ }
+
+ /**
+ * Returns `true` if this Grouper groups by an index level.
+ */
+ isLevelGrouper(): boolean {
+ return this.level !== undefined;
+ }
+
+ /**
+ * Returns `true` if this Grouper groups by a column key.
+ */
+ isKeyGrouper(): boolean {
+ return this.key !== undefined && this.freq === undefined && this.level === undefined;
+ }
+
+ /**
+ * Returns a human-readable string for debugging.
+ */
+ toString(): string {
+ const parts: string[] = [];
+ if (this.key !== undefined) parts.push(`key="${this.key}"`);
+ if (this.freq !== undefined) parts.push(`freq="${this.freq}"`);
+ if (this.level !== undefined) parts.push(`level=${String(this.level)}`);
+ if (this.sort) parts.push("sort=true");
+ if (!this.dropna) parts.push("dropna=false");
+ return `Grouper(${parts.join(", ")})`;
+ }
+}
+
+/**
+ * Returns `true` when `value` is a {@link Grouper} instance.
+ *
+ * @example
+ * ```ts
+ * isGrouper(new Grouper({ key: "col" })); // true
+ * isGrouper("col"); // false
+ * ```
+ */
+export function isGrouper(value: unknown): value is Grouper {
+ return value instanceof Grouper;
+}
diff --git a/src/groupby/index.ts b/src/groupby/index.ts
index 9ac6f8c3..7f957880 100644
--- a/src/groupby/index.ts
+++ b/src/groupby/index.ts
@@ -8,3 +8,5 @@ export { DataFrameGroupBy, SeriesGroupBy } from "./groupby.ts";
export type { AggFn, AggName, AggSpec } from "./groupby.ts";
export { NamedAgg, namedAgg, isNamedAggSpec } from "./named_agg.ts";
export type { NamedAggSpec } from "./named_agg.ts";
+export { Grouper, isGrouper } from "./grouper.ts";
+export type { GrouperOptions } from "./grouper.ts";
diff --git a/src/index.ts b/src/index.ts
index 5770bb34..e3976f1d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -48,6 +48,8 @@ export { DataFrameGroupBy, SeriesGroupBy } from "./groupby/index.ts";
export type { AggFn, AggName, AggSpec } from "./groupby/index.ts";
export { NamedAgg, namedAgg, isNamedAggSpec } from "./groupby/index.ts";
export type { NamedAggSpec } from "./groupby/index.ts";
+export { Grouper, isGrouper } from "./groupby/index.ts";
+export type { GrouperOptions } from "./groupby/index.ts";
export { describe, quantile } from "./stats/index.ts";
export type { DescribeOptions } from "./stats/index.ts";
export { readCsv, toCsv } from "./io/index.ts";
@@ -75,6 +77,13 @@ export {
dataFrameRollingAgg,
} from "./window/index.ts";
export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./window/index.ts";
+export {
+ BaseIndexer,
+ FixedForwardWindowIndexer,
+ VariableOffsetWindowIndexer,
+ applyIndexer,
+} from "./window/index.ts";
+export type { WindowBoundsOptions, WindowBounds } from "./window/index.ts";
export { DataFrameEwm } from "./core/index.ts";
export { CategoricalAccessor } from "./core/index.ts";
export type { CatSeriesLike } from "./core/index.ts";
@@ -685,3 +694,5 @@ export type {
} from "./stats/index.ts";
export { hashPandasObject } from "./stats/index.ts";
export type { HashPandasObjectOptions } from "./stats/index.ts";
+export { hashArray } from "./stats/index.ts";
+export { hashBijectArray, hashBijectInverse } from "./stats/index.ts";
diff --git a/src/stats/hash_array.ts b/src/stats/hash_array.ts
new file mode 100644
index 00000000..b81db3d2
--- /dev/null
+++ b/src/stats/hash_array.ts
@@ -0,0 +1,106 @@
+/**
+ * hashArray — element-wise FNV-1a 64-bit hashing of an array of scalars.
+ *
+ * Mirrors `pandas.util.hash_array`, which accepts a 1-D array-like and returns
+ * a `numpy.ndarray` of `uint64` hash values, one per element. In tsb the
+ * result is a plain `number[]` (float64 bit-pattern of the uint64).
+ *
+ * @example
+ * ```ts
+ * import { hashArray } from "tsb";
+ *
+ * const hashes = hashArray([1, 2, 3, null, "hello"]);
+ * // hashes[0] === hashes[0] (deterministic)
+ * // hashes[0] !== hashes[1] (with overwhelming probability)
+ * ```
+ *
+ * @module
+ */
+
+import type { Scalar } from "../types.ts";
+
+// ─── FNV-1a 64-bit constants ──────────────────────────────────────────────────
+
+const FNV_PRIME = BigInt("0x00000100000001B3");
+const FNV_OFFSET = BigInt("0xcbf29ce484222325");
+const MASK64 = (BigInt(1) << BigInt(64)) - BigInt(1);
+
+function fnvByte(hash: bigint, byte: number): bigint {
+ return ((hash ^ BigInt(byte)) * FNV_PRIME) & MASK64;
+}
+
+function fnvString(hash: bigint, s: string): bigint {
+ let h = hash;
+ for (let i = 0; i < s.length; i++) {
+ const code = s.charCodeAt(i);
+ if (code < 0x80) {
+ h = fnvByte(h, code);
+ } else if (code < 0x800) {
+ h = fnvByte(h, 0xc0 | (code >> 6));
+ h = fnvByte(h, 0x80 | (code & 0x3f));
+ } else {
+ h = fnvByte(h, 0xe0 | (code >> 12));
+ h = fnvByte(h, 0x80 | ((code >> 6) & 0x3f));
+ h = fnvByte(h, 0x80 | (code & 0x3f));
+ }
+ }
+ return h;
+}
+
+function fnvScalar(hash: bigint, val: Scalar): bigint {
+ if (val === null || val === undefined) {
+ return fnvByte(fnvByte(hash, 0xfe), 0xfe);
+ }
+ if (typeof val === "boolean") {
+ return fnvByte(hash, val ? 1 : 0);
+ }
+ if (typeof val === "number") {
+ if (Number.isNaN(val)) {
+ return fnvByte(fnvByte(hash, 0xfd), 0xfd);
+ }
+ const buf = new ArrayBuffer(8);
+ new DataView(buf).setFloat64(0, val, true);
+ const bytes = new Uint8Array(buf);
+ let h = hash;
+ for (let i = 0; i < 8; i++) {
+ h = fnvByte(h, bytes[i] ?? 0);
+ }
+ return h;
+ }
+ if (typeof val === "bigint") {
+ return fnvString(hash, val.toString());
+ }
+ if (val instanceof Date) {
+ return fnvString(hash, String(val.getTime()));
+ }
+ return fnvString(hash, String(val));
+}
+
+// ─── public API ───────────────────────────────────────────────────────────────
+
+/**
+ * Compute FNV-1a 64-bit hash values for each element of `arr`.
+ *
+ * The returned array has the same length as `arr`. Each element is the
+ * `uint64` hash encoded as a `number` (float64 bit-pattern). Equal inputs
+ * always produce equal outputs; unequal inputs produce different outputs with
+ * overwhelming probability.
+ *
+ * Mirrors `pandas.util.hash_array(arr)` (without the `encoding` / `hash_key`
+ * options, which are pandas internals not needed for typical use).
+ *
+ * @param arr - Array of scalar values to hash.
+ * @returns Array of hash values (one per element).
+ *
+ * @example
+ * ```ts
+ * import { hashArray } from "tsb";
+ *
+ * const h = hashArray(["a", "b", "a"]);
+ * h[0] === h[2]; // true
+ * h[0] !== h[1]; // true (with overwhelming probability)
+ * ```
+ */
+export function hashArray(arr: readonly Scalar[]): number[] {
+ return arr.map((val) => Number(fnvScalar(FNV_OFFSET, val)));
+}
diff --git a/src/stats/hash_biject_array.ts b/src/stats/hash_biject_array.ts
new file mode 100644
index 00000000..819b91b5
--- /dev/null
+++ b/src/stats/hash_biject_array.ts
@@ -0,0 +1,135 @@
+/**
+ * hashBijectArray — bijective integer mapping for categorical arrays.
+ *
+ * Mirrors `pandas.util.hash_biject_array` (a semi-public pandas utility):
+ * given an array of scalars, return an array of non-negative integers such
+ * that **identical values always map to the same integer** and **distinct
+ * values always map to different integers** — a bijection on the unique set.
+ *
+ * The integers are contiguous and zero-based (first-occurrence order),
+ * matching pandas' internal `rizer.get_count_table` behaviour for
+ * categorical encoding.
+ *
+ * @example
+ * ```ts
+ * import { hashBijectArray } from "tsb";
+ *
+ * const codes = hashBijectArray(["a", "b", "a", "c", "b"]);
+ * // codes → [0, 1, 0, 2, 1]
+ *
+ * const nullCodes = hashBijectArray([1, null, 1, 2, null]);
+ * // nullCodes → [0, 1, 0, 2, 1]
+ * ```
+ *
+ * @module
+ */
+
+import type { Scalar } from "../types.ts";
+
+// ─── key canonicalization ─────────────────────────────────────────────────────
+
+/**
+ * Convert a scalar to a stable string key for use in a `Map`.
+ *
+ * The key is **type-tagged** so that `1` (number) and `"1"` (string) do not
+ * collide — mirroring pandas' type-sensitive hashing.
+ */
+function toKey(val: Scalar): string {
+ if (val === null || val === undefined) {
+ return "\x00null";
+ }
+ if (typeof val === "boolean") {
+ return `\x00bool:${val ? "1" : "0"}`;
+ }
+ if (typeof val === "number") {
+ if (Number.isNaN(val)) {
+ return "\x00nan";
+ }
+ return `\x00num:${val}`;
+ }
+ if (typeof val === "bigint") {
+ return `\x00bigint:${val}`;
+ }
+ if (val instanceof Date) {
+ return `\x00date:${val.getTime()}`;
+ }
+ // string
+ return `\x00str:${val}`;
+}
+
+// ─── public API ───────────────────────────────────────────────────────────────
+
+/**
+ * Compute a bijective (contiguous, zero-based) integer code for each element
+ * of `arr`.
+ *
+ * Identical values receive the same code; distinct values receive different
+ * codes. Codes are assigned in first-occurrence order so the result is
+ * deterministic.
+ *
+ * This is useful for converting a categorical array into a compact integer
+ * representation without losing the uniqueness guarantee — the standard
+ * first step before building a hash table or index structure.
+ *
+ * Mirrors `pandas.util.hash_biject_array(arr)`.
+ *
+ * @param arr - Array of scalar values to encode.
+ * @returns Array of non-negative integer codes (same length as `arr`).
+ *
+ * @example
+ * ```ts
+ * import { hashBijectArray } from "tsb";
+ *
+ * hashBijectArray(["cat", "dog", "cat"]); // [0, 1, 0]
+ * hashBijectArray([true, false, true]); // [0, 1, 0]
+ * hashBijectArray([1, null, 1, 2]); // [0, 1, 0, 2]
+ * ```
+ */
+export function hashBijectArray(arr: readonly Scalar[]): number[] {
+ const map = new Map();
+ let nextCode = 0;
+
+ return arr.map((val) => {
+ const key = toKey(val);
+ const existing = map.get(key);
+ if (existing !== undefined) {
+ return existing;
+ }
+ const code = nextCode++;
+ map.set(key, code);
+ return code;
+ });
+}
+
+/**
+ * Return the unique values from `arr` in first-occurrence order — the inverse
+ * mapping of {@link hashBijectArray}.
+ *
+ * `inverseMap(arr)[code]` gives the original scalar value for the code
+ * returned by `hashBijectArray(arr)`.
+ *
+ * @param arr - Array of scalar values.
+ * @returns Unique values in first-occurrence order.
+ *
+ * @example
+ * ```ts
+ * import { hashBijectInverse } from "tsb";
+ *
+ * hashBijectInverse(["cat", "dog", "cat"]); // ["cat", "dog"]
+ * hashBijectInverse([3, 1, 4, 1, 5, 9]); // [3, 1, 4, 5, 9]
+ * ```
+ */
+export function hashBijectInverse(arr: readonly Scalar[]): Scalar[] {
+ const seen = new Map();
+ const result: Scalar[] = [];
+
+ for (const val of arr) {
+ const key = toKey(val);
+ if (!seen.has(key)) {
+ seen.set(key, val);
+ result.push(val);
+ }
+ }
+
+ return result;
+}
diff --git a/src/stats/index.ts b/src/stats/index.ts
index 2dd26e63..06be9af9 100644
--- a/src/stats/index.ts
+++ b/src/stats/index.ts
@@ -503,3 +503,5 @@ export type {
} from "./style.ts";
export { hashPandasObject } from "./hash_pandas_object.ts";
export type { HashPandasObjectOptions } from "./hash_pandas_object.ts";
+export { hashArray } from "./hash_array.ts";
+export { hashBijectArray, hashBijectInverse } from "./hash_biject_array.ts";
diff --git a/src/window/index.ts b/src/window/index.ts
index 378222e2..3824953e 100644
--- a/src/window/index.ts
+++ b/src/window/index.ts
@@ -17,3 +17,10 @@ export {
dataFrameRollingAgg,
} from "./rolling_apply.ts";
export type { RollingApplyOptions, RollingAggOptions, AggFunctions } from "./rolling_apply.ts";
+export {
+ BaseIndexer,
+ FixedForwardWindowIndexer,
+ VariableOffsetWindowIndexer,
+ applyIndexer,
+} from "./indexers.ts";
+export type { WindowBoundsOptions, WindowBounds } from "./indexers.ts";
diff --git a/src/window/indexers.ts b/src/window/indexers.ts
new file mode 100644
index 00000000..2394d3d8
--- /dev/null
+++ b/src/window/indexers.ts
@@ -0,0 +1,247 @@
+/**
+ * indexers — custom window indexers for rolling computations.
+ *
+ * Mirrors `pandas.api.indexers`:
+ * - {@link BaseIndexer} — abstract base class; subclass and override `getWindowBounds`.
+ * - {@link FixedForwardWindowIndexer} — forward-looking window of fixed size.
+ * - {@link VariableOffsetWindowIndexer} — window driven by a per-row offset array.
+ *
+ * A window indexer produces a pair of parallel arrays `[start, end]` where each
+ * element `[start[i], end[i])` is the half-open interval of positions included
+ * in the window centred on row `i`. This follows pandas' internal convention
+ * exactly, making it straightforward to port custom rolling logic.
+ *
+ * @example
+ * ```ts
+ * import { FixedForwardWindowIndexer } from "tsb";
+ *
+ * const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ * const [start, end] = idx.getWindowBounds(5);
+ * // start = [0, 1, 2, 3, 4]
+ * // end = [3, 4, 5, 5, 5]
+ * ```
+ *
+ * @module
+ */
+
+// ─── public types ─────────────────────────────────────────────────────────────
+
+/** Options passed to {@link BaseIndexer.getWindowBounds}. */
+export interface WindowBoundsOptions {
+ /** Total number of rows in the series. */
+ readonly numValues: number;
+ /**
+ * Minimum number of valid observations required for the result to be
+ * non-null. Informational — the indexer itself does not filter, but
+ * consumers (e.g. Rolling) use it to decide whether to emit null.
+ */
+ readonly minPeriods?: number;
+ /**
+ * Centre the window label. How this is interpreted is up to the subclass;
+ * `FixedForwardWindowIndexer` ignores this flag (it is always forward-looking).
+ */
+ readonly center?: boolean;
+}
+
+/** The return type of {@link BaseIndexer.getWindowBounds}: `[startArray, endArray]`. */
+export type WindowBounds = [Int32Array, Int32Array];
+
+// ═════════════════════════════════════════════════════════════════════════════
+// BaseIndexer
+// ═════════════════════════════════════════════════════════════════════════════
+
+/**
+ * Abstract base class for custom window indexers.
+ *
+ * Subclass this and implement {@link getWindowBounds} to define an arbitrary
+ * window shape. The returned arrays must satisfy:
+ *
+ * - `start[i] >= 0`
+ * - `end[i] <= numValues`
+ * - `start[i] <= end[i]` (empty windows are allowed)
+ *
+ * Mirrors `pandas.api.indexers.BaseIndexer`.
+ */
+export abstract class BaseIndexer {
+ /** Fixed window size, if applicable. May be `null` for variable-size indexers. */
+ readonly windowSize: number | null;
+
+ constructor(options?: { windowSize?: number | null }) {
+ this.windowSize = options?.windowSize ?? null;
+ }
+
+ /**
+ * Compute the `[start, end)` bounds for every row.
+ *
+ * @param numValues - Number of rows in the series.
+ * @param options - Additional hints (minPeriods, center).
+ * @returns Pair of `Int32Array` with length `numValues`.
+ */
+ abstract getWindowBounds(numValues: number, options?: WindowBoundsOptions): WindowBounds;
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// FixedForwardWindowIndexer
+// ═════════════════════════════════════════════════════════════════════════════
+
+/**
+ * A window indexer that looks **forward** from the current position.
+ *
+ * For row `i` the window covers `[i, i + windowSize)`, clamped to
+ * `[0, numValues)`. This is the opposite of the default trailing window
+ * used by `Rolling`.
+ *
+ * Mirrors `pandas.api.indexers.FixedForwardWindowIndexer`.
+ *
+ * @example
+ * ```ts
+ * import { FixedForwardWindowIndexer } from "tsb";
+ *
+ * const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ * const [start, end] = idx.getWindowBounds(5);
+ * // i=0: [0, 3), i=1: [1, 4), i=2: [2, 5), i=3: [3, 5), i=4: [4, 5)
+ * ```
+ */
+export class FixedForwardWindowIndexer extends BaseIndexer {
+ /**
+ * @param options.windowSize - Number of rows in each forward window (≥ 1).
+ */
+ constructor(options: { windowSize: number }) {
+ if (!Number.isInteger(options.windowSize) || options.windowSize < 1) {
+ throw new RangeError(
+ `FixedForwardWindowIndexer: windowSize must be a positive integer, got ${options.windowSize}`,
+ );
+ }
+ super({ windowSize: options.windowSize });
+ }
+
+ getWindowBounds(numValues: number, _options?: WindowBoundsOptions): WindowBounds {
+ const w = this.windowSize as number;
+ const start = new Int32Array(numValues);
+ const end = new Int32Array(numValues);
+ for (let i = 0; i < numValues; i++) {
+ start[i] = i;
+ end[i] = Math.min(i + w, numValues);
+ }
+ return [start, end];
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// VariableOffsetWindowIndexer
+// ═════════════════════════════════════════════════════════════════════════════
+
+/**
+ * A window indexer driven by a per-row offset array.
+ *
+ * For row `i` the window covers `[i - offset[i], i + 1)` (trailing) or
+ * `[i, i + offset[i] + 1)` (forward), depending on the `forward` flag.
+ *
+ * Each element of `offsets` must be a non-negative integer; the window is
+ * clamped to the valid range `[0, numValues)`.
+ *
+ * Mirrors the spirit of `pandas.api.indexers.VariableOffsetWindowIndexer`,
+ * adapted for a purely positional (non-datetime) context.
+ *
+ * @example
+ * ```ts
+ * import { VariableOffsetWindowIndexer } from "tsb";
+ *
+ * // Trailing window of variable depth
+ * const idx = new VariableOffsetWindowIndexer({ offsets: [0, 1, 2, 1, 0] });
+ * const [start, end] = idx.getWindowBounds(5);
+ * // i=0: [0,1), i=1: [0,2), i=2: [0,3), i=3: [2,4), i=4: [4,5)
+ * ```
+ */
+export class VariableOffsetWindowIndexer extends BaseIndexer {
+ private readonly _offsets: readonly number[];
+ private readonly _forward: boolean;
+
+ /**
+ * @param options.offsets - Per-row look-back (or look-forward) depth.
+ * Length must equal the series length passed to `getWindowBounds`.
+ * @param options.forward - If `true`, look forward instead of backward.
+ * Defaults to `false`.
+ */
+ constructor(options: { offsets: readonly number[]; forward?: boolean }) {
+ super({ windowSize: null });
+ for (let i = 0; i < options.offsets.length; i++) {
+ const o = options.offsets[i];
+ if (o === undefined || !Number.isInteger(o) || o < 0) {
+ throw new RangeError(
+ `VariableOffsetWindowIndexer: offsets[${i}] must be a non-negative integer, got ${o}`,
+ );
+ }
+ }
+ this._offsets = options.offsets;
+ this._forward = options.forward ?? false;
+ }
+
+ getWindowBounds(numValues: number, _options?: WindowBoundsOptions): WindowBounds {
+ if (this._offsets.length !== numValues) {
+ throw new RangeError(
+ `VariableOffsetWindowIndexer: offsets length (${this._offsets.length}) ` +
+ `does not match numValues (${numValues})`,
+ );
+ }
+ const start = new Int32Array(numValues);
+ const end = new Int32Array(numValues);
+ for (let i = 0; i < numValues; i++) {
+ const offset = this._offsets[i] as number;
+ if (this._forward) {
+ start[i] = i;
+ end[i] = Math.min(i + offset + 1, numValues);
+ } else {
+ start[i] = Math.max(0, i - offset);
+ end[i] = i + 1;
+ }
+ }
+ return [start, end];
+ }
+}
+
+// ═════════════════════════════════════════════════════════════════════════════
+// applyIndexer — helper consumed by Rolling and testing code
+// ═════════════════════════════════════════════════════════════════════════════
+
+/**
+ * Apply an aggregation function to each window defined by a {@link BaseIndexer}.
+ *
+ * Returns a numeric array of length `numValues`. Positions whose window
+ * contains fewer than `minPeriods` valid (finite, non-null) numbers produce
+ * `null`.
+ *
+ * @example
+ * ```ts
+ * import { FixedForwardWindowIndexer, applyIndexer } from "tsb";
+ *
+ * const idx = new FixedForwardWindowIndexer({ windowSize: 2 });
+ * const result = applyIndexer(idx, [1, 2, 3, 4, 5], (nums) => nums.reduce((a, b) => a + b, 0));
+ * // [3, 5, 7, 9, 5]
+ * ```
+ */
+export function applyIndexer(
+ indexer: BaseIndexer,
+ values: readonly (number | null | undefined)[],
+ agg: (nums: readonly number[]) => number,
+ minPeriods = 1,
+): (number | null)[] {
+ const n = values.length;
+ const [start, end] = indexer.getWindowBounds(n);
+ const result: (number | null)[] = Array.from({ length: n }, (): null => null);
+
+ for (let i = 0; i < n; i++) {
+ const s = start[i] as number;
+ const e = end[i] as number;
+ const nums: number[] = [];
+ for (let j = s; j < e; j++) {
+ const v = values[j];
+ if (v !== null && v !== undefined && typeof v === "number" && !Number.isNaN(v)) {
+ nums.push(v);
+ }
+ }
+ result[i] = nums.length >= minPeriods ? agg(nums) : null;
+ }
+
+ return result;
+}
diff --git a/tests/core/itertuples_items.test.ts b/tests/core/itertuples_items.test.ts
new file mode 100644
index 00000000..97b380b6
--- /dev/null
+++ b/tests/core/itertuples_items.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test";
+import { DataFrame, Series } from "../../src/index.ts";
+
+describe("Series.items() and Series.iteritems()", () => {
+ test("items() yields (label, value) pairs", () => {
+ const s = new Series({ data: [10, 20, 30], index: ["a", "b", "c"] });
+ const pairs = [...s.items()];
+ expect(pairs).toEqual([
+ ["a", 10],
+ ["b", 20],
+ ["c", 30],
+ ]);
+ });
+
+ test("iteritems() is an alias for items()", () => {
+ const s = new Series({ data: [1, 2], index: [10, 20] });
+ expect([...s.iteritems()]).toEqual([...s.items()]);
+ });
+
+ test("items() on empty series", () => {
+ const s = new Series({ data: [], index: [] });
+ expect([...s.items()]).toEqual([]);
+ });
+
+ test("items() with default numeric index", () => {
+ const s = new Series({ data: ["x", "y"] });
+ const pairs = [...s.items()];
+ expect(pairs[0]).toEqual([0, "x"]);
+ expect(pairs[1]).toEqual([1, "y"]);
+ });
+});
+
+describe("DataFrame.itertuples()", () => {
+ test("yields row objects with Index field", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2], b: ["x", "y"] });
+ const rows = [...df.itertuples()];
+ expect(rows).toHaveLength(2);
+ expect(rows[0]).toEqual({ Index: 0, a: 1, b: "x" });
+ expect(rows[1]).toEqual({ Index: 1, a: 2, b: "y" });
+ });
+
+ test("index=false omits Index field", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2], b: ["x", "y"] });
+ const rows = [...df.itertuples(false)];
+ expect(rows[0]).toEqual({ a: 1, b: "x" });
+ expect("Index" in (rows[0] ?? {})).toBe(false);
+ });
+
+ test("custom index labels appear in Index field", () => {
+ const df = DataFrame.fromColumns({ v: [100] }, { index: ["r0"] });
+ const rows = [...df.itertuples()];
+ expect(rows[0]?.["Index"]).toBe("r0");
+ });
+
+ test("empty dataframe yields no rows", () => {
+ const df = DataFrame.fromColumns({ a: [] as number[] });
+ expect([...df.itertuples()]).toEqual([]);
+ });
+});
diff --git a/tests/core/series.map.test.ts b/tests/core/series.map.test.ts
new file mode 100644
index 00000000..4ab2246b
--- /dev/null
+++ b/tests/core/series.map.test.ts
@@ -0,0 +1,176 @@
+/**
+ * Tests for Series.map() — function, Record, Series, and Map overloads.
+ *
+ * Mirrors the behaviour of `pandas.Series.map`:
+ * - function mapper: called element-wise, receives (value, index, pos)
+ * - Record mapper: string-key lookup, missing → null
+ * - Series mapper: label-based lookup in mapper Series, missing → null
+ * - Map mapper: direct Scalar-key lookup, missing → null
+ * - naAction "ignore": pass-through NA values unchanged
+ */
+
+import { describe, expect, it } from "bun:test";
+import { Index, Series } from "../../src/index.ts";
+
+describe("Series.map — function overload", () => {
+ it("transforms each value", () => {
+ const s = new Series({ data: [1, 2, 3], name: "x" });
+ const result = s.map((v) => (v as number) * 10);
+ expect(result.toArray()).toEqual([10, 20, 30]);
+ expect(result.name).toBe("x");
+ });
+
+ it("receives (value, label, position) arguments", () => {
+ const s = new Series({
+ data: ["a", "b"],
+ index: new Index(["x", "y"]),
+ });
+ const result = s.map((v, idx, pos) => `${String(v)}-${String(idx)}-${pos}`);
+ expect(result.toArray()).toEqual(["a-x-0", "b-y-1"]);
+ });
+
+ it("returns a new Series with the same index", () => {
+ const idx = new Index([10, 20, 30]);
+ const s = new Series({ data: [1, 2, 3], index: idx });
+ const result = s.map((v) => (v as number) + 100);
+ expect(result.index.toArray()).toEqual([10, 20, 30]);
+ });
+});
+
+describe("Series.map — Record overload", () => {
+ it("maps string keys to values", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ const result = s.map({ "1": "one", "2": "two", "3": "three" });
+ expect(result.toArray()).toEqual(["one", "two", "three"]);
+ });
+
+ it("returns null for missing keys", () => {
+ const s = new Series({ data: [1, 2, 99] });
+ const result = s.map({ "1": "one", "2": "two" });
+ expect(result.toArray()).toEqual(["one", "two", null]);
+ });
+
+ it("preserves index", () => {
+ const s = new Series({ data: ["a", "b"], index: new Index([5, 6]) });
+ const result = s.map({ a: 1, b: 2 });
+ expect(result.index.toArray()).toEqual([5, 6]);
+ expect(result.toArray()).toEqual([1, 2]);
+ });
+
+ it("handles NA values with naAction ignore", () => {
+ const s = new Series({ data: [1, null, 2] });
+ const result = s.map({ "1": "one", "2": "two" }, { naAction: "ignore" });
+ expect(result.toArray()[0]).toBe("one");
+ expect(result.toArray()[1]).toBeNull();
+ expect(result.toArray()[2]).toBe("two");
+ });
+
+ it("maps null without naAction (passes null to lookup)", () => {
+ const s = new Series({ data: [1, null, 2] });
+ const result = s.map({ "1": "one", "2": "two" });
+ // null stringifies to "null", which is not in the dict → null
+ expect(result.toArray()).toEqual(["one", null, "two"]);
+ });
+
+ it("empty Series returns empty result", () => {
+ const s = new Series({ data: [] as number[] });
+ const result = s.map({ "1": "one" });
+ expect(result.toArray()).toEqual([]);
+ });
+});
+
+describe("Series.map — Series overload", () => {
+ it("maps values using index-label lookup", () => {
+ const s = new Series({ data: ["a", "b", "c"] });
+ const mapper = new Series({
+ data: [1, 2, 3],
+ index: new Index(["a", "b", "c"]),
+ });
+ const result = s.map(mapper);
+ expect(result.toArray()).toEqual([1, 2, 3]);
+ });
+
+ it("returns null for values not in mapper index", () => {
+ const s = new Series({ data: ["a", "b", "x"] });
+ const mapper = new Series({
+ data: [1, 2],
+ index: new Index(["a", "b"]),
+ });
+ const result = s.map(mapper);
+ expect(result.toArray()).toEqual([1, 2, null]);
+ });
+
+ it("preserves the original Series index", () => {
+ const s = new Series({
+ data: ["a", "b"],
+ index: new Index([10, 20]),
+ });
+ const mapper = new Series({ data: [100, 200], index: new Index(["a", "b"]) });
+ const result = s.map(mapper);
+ expect(result.index.toArray()).toEqual([10, 20]);
+ expect(result.toArray()).toEqual([100, 200]);
+ });
+
+ it("naAction ignore skips NA values", () => {
+ const s = new Series({ data: ["a", null, "b"] });
+ const mapper = new Series({ data: [1, 2], index: new Index(["a", "b"]) });
+ const result = s.map(mapper, { naAction: "ignore" });
+ expect(result.toArray()[0]).toBe(1);
+ expect(result.toArray()[1]).toBeNull();
+ expect(result.toArray()[2]).toBe(2);
+ });
+});
+
+describe("Series.map — Map overload", () => {
+ it("maps using ES6 Map by scalar key", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ const m = new Map([
+ [1, "one"],
+ [2, "two"],
+ [3, "three"],
+ ]);
+ const result = s.map(m);
+ expect(result.toArray()).toEqual(["one", "two", "three"]);
+ });
+
+ it("returns null for keys not in Map", () => {
+ const s = new Series({ data: [1, 2, 99] });
+ const m = new Map([[1, "one"], [2, "two"]]);
+ const result = s.map(m);
+ expect(result.toArray()).toEqual(["one", "two", null]);
+ });
+
+ it("handles null keys explicitly in Map", () => {
+ const s = new Series({ data: [1, null, 2] });
+ const m = new Map([
+ [1, "one"],
+ [null, "nullval"],
+ [2, "two"],
+ ]);
+ const result = s.map(m);
+ expect(result.toArray()).toEqual(["one", "nullval", "two"]);
+ });
+
+ it("naAction ignore leaves NA values unchanged", () => {
+ const s = new Series({ data: [1, null, 2] });
+ const m = new Map([
+ [1, "one"],
+ [2, "two"],
+ ]);
+ const result = s.map(m, { naAction: "ignore" });
+ expect(result.toArray()[0]).toBe("one");
+ expect(result.toArray()[1]).toBeNull();
+ expect(result.toArray()[2]).toBe("two");
+ });
+
+ it("preserves Series name and index", () => {
+ const s = new Series({ data: ["x", "y"], name: "col", index: new Index([3, 4]) });
+ const m = new Map([
+ ["x", 10],
+ ["y", 20],
+ ]);
+ const result = s.map(m);
+ expect(result.name).toBe("col");
+ expect(result.index.toArray()).toEqual([3, 4]);
+ });
+});
diff --git a/tests/groupby/grouper.test.ts b/tests/groupby/grouper.test.ts
new file mode 100644
index 00000000..8ad2798a
--- /dev/null
+++ b/tests/groupby/grouper.test.ts
@@ -0,0 +1,108 @@
+/**
+ * Tests for pd.Grouper — mirrors pandas.Grouper behaviour.
+ */
+
+import { describe, expect, test } from "bun:test";
+import { Grouper, isGrouper } from "../../src/index.ts";
+
+describe("Grouper constructor", () => {
+ test("defaults", () => {
+ const g = new Grouper();
+ expect(g.key).toBeUndefined();
+ expect(g.freq).toBeUndefined();
+ expect(g.axis).toBe(0);
+ expect(g.sort).toBe(false);
+ expect(g.dropna).toBe(true);
+ expect(g.level).toBeUndefined();
+ expect(g.closed).toBeUndefined();
+ expect(g.label).toBeUndefined();
+ });
+
+ test("key grouper", () => {
+ const g = new Grouper({ key: "dept" });
+ expect(g.key).toBe("dept");
+ expect(g.isKeyGrouper()).toBe(true);
+ expect(g.isFreqGrouper()).toBe(false);
+ expect(g.isLevelGrouper()).toBe(false);
+ });
+
+ test("freq grouper", () => {
+ const g = new Grouper({ key: "date", freq: "1D" });
+ expect(g.freq).toBe("1D");
+ expect(g.isFreqGrouper()).toBe(true);
+ expect(g.isKeyGrouper()).toBe(false);
+ });
+
+ test("level grouper", () => {
+ const g = new Grouper({ level: 0 });
+ expect(g.level).toBe(0);
+ expect(g.isLevelGrouper()).toBe(true);
+ expect(g.isKeyGrouper()).toBe(false);
+ });
+
+ test("sort option", () => {
+ const g = new Grouper({ key: "x", sort: true });
+ expect(g.sort).toBe(true);
+ });
+
+ test("dropna option", () => {
+ const g = new Grouper({ key: "x", dropna: false });
+ expect(g.dropna).toBe(false);
+ });
+
+ test("closed and label options", () => {
+ const g = new Grouper({ key: "date", freq: "ME", closed: "left", label: "right" });
+ expect(g.closed).toBe("left");
+ expect(g.label).toBe("right");
+ });
+
+ test("axis option", () => {
+ const g = new Grouper({ axis: 1 });
+ expect(g.axis).toBe(1);
+ });
+
+ test("level by name", () => {
+ const g = new Grouper({ level: "city" });
+ expect(g.level).toBe("city");
+ expect(g.isLevelGrouper()).toBe(true);
+ });
+});
+
+describe("Grouper.toString()", () => {
+ test("empty grouper", () => {
+ expect(new Grouper().toString()).toBe("Grouper()");
+ });
+
+ test("key-only grouper", () => {
+ expect(new Grouper({ key: "dept" }).toString()).toBe('Grouper(key="dept")');
+ });
+
+ test("freq grouper", () => {
+ expect(new Grouper({ key: "date", freq: "ME" }).toString()).toBe('Grouper(key="date", freq="ME")');
+ });
+
+ test("sort and dropna", () => {
+ const s = new Grouper({ key: "x", sort: true, dropna: false }).toString();
+ expect(s).toContain("sort=true");
+ expect(s).toContain("dropna=false");
+ });
+
+ test("level grouper", () => {
+ expect(new Grouper({ level: 0 }).toString()).toBe("Grouper(level=0)");
+ });
+});
+
+describe("isGrouper()", () => {
+ test("returns true for Grouper instances", () => {
+ expect(isGrouper(new Grouper())).toBe(true);
+ expect(isGrouper(new Grouper({ key: "x" }))).toBe(true);
+ });
+
+ test("returns false for non-Grouper values", () => {
+ expect(isGrouper("col")).toBe(false);
+ expect(isGrouper(42)).toBe(false);
+ expect(isGrouper(null)).toBe(false);
+ expect(isGrouper(undefined)).toBe(false);
+ expect(isGrouper({ key: "x" })).toBe(false);
+ });
+});
diff --git a/tests/stats/hash_array.test.ts b/tests/stats/hash_array.test.ts
new file mode 100644
index 00000000..c84d8fbe
--- /dev/null
+++ b/tests/stats/hash_array.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test";
+import { hashArray } from "../../src/index.ts";
+
+describe("hashArray", () => {
+ test("returns array of same length", () => {
+ const h = hashArray([1, 2, 3]);
+ expect(h).toHaveLength(3);
+ });
+
+ test("equal values produce equal hashes", () => {
+ const h = hashArray(["a", "b", "a"]);
+ expect(h[0]).toBe(h[2]);
+ });
+
+ test("different values produce different hashes (spot check)", () => {
+ const h = hashArray([1, 2, 3]);
+ expect(h[0]).not.toBe(h[1]);
+ expect(h[1]).not.toBe(h[2]);
+ });
+
+ test("null and undefined have distinct hashes from numbers", () => {
+ const h = hashArray([null, undefined, 0]);
+ expect(h[2]).not.toBe(h[0]);
+ });
+
+ test("NaN has its own hash", () => {
+ const h = hashArray([Number.NaN, 0, 1]);
+ expect(h[0]).not.toBe(h[1]);
+ expect(h[0]).not.toBe(h[2]);
+ });
+
+ test("empty array", () => {
+ expect(hashArray([])).toEqual([]);
+ });
+
+ test("boolean values", () => {
+ const h = hashArray([true, false, true]);
+ expect(h[0]).toBe(h[2]);
+ expect(h[0]).not.toBe(h[1]);
+ });
+
+ test("deterministic across calls", () => {
+ const h1 = hashArray([1, "hello", null]);
+ const h2 = hashArray([1, "hello", null]);
+ expect(h1).toEqual(h2);
+ });
+
+ test("all results are finite numbers", () => {
+ const arr = [1, "x", null, true, 0];
+ const h = hashArray(arr);
+ expect(h.every((v) => typeof v === "number" && Number.isFinite(v))).toBe(true);
+ });
+
+ test("strings produce equal hashes for equal strings", () => {
+ const h = hashArray(["hello", "world", "hello"]);
+ expect(h[0]).toBe(h[2]);
+ expect(h[0]).not.toBe(h[1]);
+ });
+});
diff --git a/tests/stats/hash_biject_array.test.ts b/tests/stats/hash_biject_array.test.ts
new file mode 100644
index 00000000..7bb52883
--- /dev/null
+++ b/tests/stats/hash_biject_array.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Tests for hashBijectArray and hashBijectInverse.
+ *
+ * Mirrors `pandas.util.hash_biject_array` semantics:
+ * - identical values → same code
+ * - distinct values → distinct codes
+ * - codes are zero-based, contiguous, first-occurrence order
+ * - type-sensitive: number 1 ≠ string "1"
+ */
+
+import { describe, expect, it } from "bun:test";
+import { hashBijectArray, hashBijectInverse } from "../../src/index.ts";
+
+describe("hashBijectArray", () => {
+ it("assigns 0-based codes in first-occurrence order", () => {
+ expect(hashBijectArray(["a", "b", "a", "c", "b"])).toEqual([0, 1, 0, 2, 1]);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(hashBijectArray([])).toEqual([]);
+ });
+
+ it("single element maps to code 0", () => {
+ expect(hashBijectArray(["x"])).toEqual([0]);
+ });
+
+ it("all identical elements map to code 0", () => {
+ expect(hashBijectArray([5, 5, 5])).toEqual([0, 0, 0]);
+ });
+
+ it("all distinct elements map to 0,1,2,...", () => {
+ expect(hashBijectArray([10, 20, 30])).toEqual([0, 1, 2]);
+ });
+
+ it("handles null / undefined", () => {
+ const result = hashBijectArray([1, null, 1, null]);
+ expect(result[0]).toBe(result[2]); // both 1s same
+ expect(result[1]).toBe(result[3]); // both nulls same
+ expect(result[0]).not.toBe(result[1]); // 1 ≠ null
+ });
+
+ it("handles booleans", () => {
+ const result = hashBijectArray([true, false, true]);
+ expect(result).toEqual([0, 1, 0]);
+ });
+
+ it("is type-sensitive: number 1 ≠ string '1'", () => {
+ const result = hashBijectArray([1, "1", 1, "1"]);
+ expect(result[0]).toBe(result[2]); // same numbers
+ expect(result[1]).toBe(result[3]); // same strings
+ expect(result[0]).not.toBe(result[1]); // different types
+ });
+
+ it("handles NaN as a distinct value", () => {
+ const result = hashBijectArray([Number.NaN, 1, Number.NaN]);
+ expect(result[0]).toBe(result[2]); // NaN === NaN in bijection
+ expect(result[0]).not.toBe(result[1]);
+ });
+
+ it("handles Date objects by time value", () => {
+ const d1 = new Date("2024-01-01");
+ const d2 = new Date("2024-01-02");
+ const d1b = new Date("2024-01-01");
+ const result = hashBijectArray([d1, d2, d1b]);
+ expect(result[0]).toBe(result[2]);
+ expect(result[0]).not.toBe(result[1]);
+ });
+
+ it("handles bigint values", () => {
+ const result = hashBijectArray([BigInt(1), BigInt(2), BigInt(1)]);
+ expect(result).toEqual([0, 1, 0]);
+ });
+
+ it("codes are contiguous (no gaps)", () => {
+ const arr = ["x", "y", "z", "x", "y"];
+ const codes = hashBijectArray(arr);
+ const uniqueCodes = new Set(codes);
+ expect(uniqueCodes.size).toBe(3);
+ const maxCode = Math.max(...codes);
+ expect(maxCode).toBe(uniqueCodes.size - 1);
+ });
+
+ it("mixed scalar types all get distinct codes", () => {
+ const arr = [1, "1", null, true, 1n];
+ const codes = hashBijectArray(arr);
+ expect(new Set(codes).size).toBe(5);
+ });
+});
+
+describe("hashBijectInverse", () => {
+ it("returns unique values in first-occurrence order", () => {
+ expect(hashBijectInverse(["a", "b", "a", "c", "b"])).toEqual(["a", "b", "c"]);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(hashBijectInverse([])).toEqual([]);
+ });
+
+ it("single element returns single-element array", () => {
+ expect(hashBijectInverse(["x"])).toEqual(["x"]);
+ });
+
+ it("handles nulls", () => {
+ expect(hashBijectInverse([null, 1, null])).toEqual([null, 1]);
+ });
+
+ it("type-sensitive: number 1 and string '1' are distinct", () => {
+ const result = hashBijectInverse([1, "1"]);
+ expect(result).toHaveLength(2);
+ expect(result[0]).toBe(1);
+ expect(result[1]).toBe("1");
+ });
+
+ it("inverse maps code back to original value", () => {
+ const arr = ["cat", "dog", "cat", "bird"];
+ const codes = hashBijectArray(arr);
+ const inverse = hashBijectInverse(arr);
+ for (let i = 0; i < arr.length; i++) {
+ expect(inverse[codes[i] as number]).toBe(arr[i]);
+ }
+ });
+
+ it("inverse length equals number of unique values", () => {
+ const arr = [1, 2, 1, 3, 2, 4];
+ expect(hashBijectInverse(arr)).toHaveLength(4);
+ });
+});
diff --git a/tests/window/indexers.test.ts b/tests/window/indexers.test.ts
new file mode 100644
index 00000000..3618f5ef
--- /dev/null
+++ b/tests/window/indexers.test.ts
@@ -0,0 +1,235 @@
+/**
+ * Tests for window indexers (BaseIndexer, FixedForwardWindowIndexer,
+ * VariableOffsetWindowIndexer, applyIndexer).
+ *
+ * Mirrors pandas.api.indexers test suite.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ applyIndexer,
+ BaseIndexer,
+ FixedForwardWindowIndexer,
+ VariableOffsetWindowIndexer,
+} from "../../src/window/indexers.ts";
+import type { WindowBounds } from "../../src/window/indexers.ts";
+
+// ─── FixedForwardWindowIndexer ────────────────────────────────────────────────
+
+describe("FixedForwardWindowIndexer", () => {
+ test("basic bounds for n=5 window=3", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ const [start, end] = idx.getWindowBounds(5);
+ expect(Array.from(start)).toEqual([0, 1, 2, 3, 4]);
+ expect(Array.from(end)).toEqual([3, 4, 5, 5, 5]);
+ });
+
+ test("window=1 — each row covers only itself", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 1 });
+ const [start, end] = idx.getWindowBounds(4);
+ expect(Array.from(start)).toEqual([0, 1, 2, 3]);
+ expect(Array.from(end)).toEqual([1, 2, 3, 4]);
+ });
+
+ test("window larger than n — all rows start at i, end clamped to n", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 10 });
+ const [start, end] = idx.getWindowBounds(3);
+ expect(Array.from(start)).toEqual([0, 1, 2]);
+ expect(Array.from(end)).toEqual([3, 3, 3]);
+ });
+
+ test("n=0 — empty output", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ const [start, end] = idx.getWindowBounds(0);
+ expect(start.length).toBe(0);
+ expect(end.length).toBe(0);
+ });
+
+ test("windowSize property exposed", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 5 });
+ expect(idx.windowSize).toBe(5);
+ });
+
+ test("throws for non-positive windowSize", () => {
+ expect(() => new FixedForwardWindowIndexer({ windowSize: 0 })).toThrow(RangeError);
+ expect(() => new FixedForwardWindowIndexer({ windowSize: -1 })).toThrow(RangeError);
+ });
+
+ test("throws for non-integer windowSize", () => {
+ expect(() => new FixedForwardWindowIndexer({ windowSize: 1.5 })).toThrow(RangeError);
+ });
+
+ test("window=n — last row window is exactly the last element", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 5 });
+ const [start, end] = idx.getWindowBounds(5);
+ expect(Array.from(start)).toEqual([0, 1, 2, 3, 4]);
+ expect(Array.from(end)).toEqual([5, 5, 5, 5, 5]);
+ });
+});
+
+// ─── VariableOffsetWindowIndexer ──────────────────────────────────────────────
+
+describe("VariableOffsetWindowIndexer — trailing (default)", () => {
+ test("basic trailing offsets", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [0, 1, 2, 1, 0] });
+ const [start, end] = idx.getWindowBounds(5);
+ expect(Array.from(start)).toEqual([0, 0, 0, 2, 4]);
+ expect(Array.from(end)).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ test("zero offsets — each row covers only itself", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [0, 0, 0] });
+ const [start, end] = idx.getWindowBounds(3);
+ expect(Array.from(start)).toEqual([0, 1, 2]);
+ expect(Array.from(end)).toEqual([1, 2, 3]);
+ });
+
+ test("large offsets clamp to 0", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [100, 100, 100] });
+ const [start, end] = idx.getWindowBounds(3);
+ expect(Array.from(start)).toEqual([0, 0, 0]);
+ expect(Array.from(end)).toEqual([1, 2, 3]);
+ });
+
+ test("throws when offsets length != numValues", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [0, 1, 2] });
+ expect(() => idx.getWindowBounds(5)).toThrow(RangeError);
+ });
+
+ test("throws on negative offset", () => {
+ expect(() => new VariableOffsetWindowIndexer({ offsets: [-1, 0] })).toThrow(RangeError);
+ });
+
+ test("windowSize is null for variable indexer", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [1, 2] });
+ expect(idx.windowSize).toBeNull();
+ });
+});
+
+describe("VariableOffsetWindowIndexer — forward", () => {
+ test("basic forward offsets", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [2, 1, 0, 1, 0], forward: true });
+ const [start, end] = idx.getWindowBounds(5);
+ expect(Array.from(start)).toEqual([0, 1, 2, 3, 4]);
+ expect(Array.from(end)).toEqual([3, 3, 3, 5, 5]);
+ });
+
+ test("forward large offsets clamp to numValues", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [100, 100, 100], forward: true });
+ const [start, end] = idx.getWindowBounds(3);
+ expect(Array.from(start)).toEqual([0, 1, 2]);
+ expect(Array.from(end)).toEqual([3, 3, 3]);
+ });
+
+ test("forward zero offsets — each row covers only itself", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [0, 0, 0], forward: true });
+ const [start, end] = idx.getWindowBounds(3);
+ expect(Array.from(start)).toEqual([0, 1, 2]);
+ expect(Array.from(end)).toEqual([1, 2, 3]);
+ });
+});
+
+// ─── applyIndexer ─────────────────────────────────────────────────────────────
+
+describe("applyIndexer", () => {
+ const sum = (nums: readonly number[]) => nums.reduce((a, b) => a + b, 0);
+ const mean = (nums: readonly number[]) => nums.reduce((a, b) => a + b, 0) / nums.length;
+
+ test("FixedForward sum window=2 over [1,2,3,4,5]", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 2 });
+ const result = applyIndexer(idx, [1, 2, 3, 4, 5], sum);
+ expect(result).toEqual([3, 5, 7, 9, 5]);
+ });
+
+ test("FixedForward mean window=3 over [1,2,3,4,5]", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ const result = applyIndexer(idx, [1, 2, 3, 4, 5], mean);
+ expect(result[0]).toBeCloseTo(2);
+ expect(result[1]).toBeCloseTo(3);
+ expect(result[2]).toBeCloseTo(4);
+ // last two windows have < minPeriods default(1) so still computed
+ expect(result[3]).toBeCloseTo(4.5);
+ expect(result[4]).toBeCloseTo(5);
+ });
+
+ test("null values are skipped", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 2 });
+ const result = applyIndexer(idx, [1, null, 3, null, 5], sum);
+ expect(result[0]).toBe(1); // only 1 valid
+ expect(result[1]).toBe(3); // only 3 valid
+ expect(result[2]).toBe(3); // only 3 valid (null skipped)
+ expect(result[3]).toBe(5); // only 5 valid
+ expect(result[4]).toBe(5); // only 5 valid
+ });
+
+ test("minPeriods respected — null when too few valid values", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ const result = applyIndexer(idx, [1, null, null, 4, 5], sum, 2);
+ // i=0: window [0,3) → [1, null, null] → 1 valid, < 2 → null
+ expect(result[0]).toBeNull();
+ // i=1: window [1,4) → [null, null, 4] → 1 valid → null
+ expect(result[1]).toBeNull();
+ // i=2: window [2,5) → [null, 4, 5] → 2 valid → 9
+ expect(result[2]).toBe(9);
+ // i=3: window [3,5) → [4, 5] → 2 valid → 9
+ expect(result[3]).toBe(9);
+ // i=4: window [4,5) → [5] → 1 valid < 2 → null
+ expect(result[4]).toBeNull();
+ });
+
+ test("VariableOffset trailing sum", () => {
+ const idx = new VariableOffsetWindowIndexer({ offsets: [0, 1, 2, 1, 0] });
+ const result = applyIndexer(idx, [10, 20, 30, 40, 50], sum);
+ // i=0: [10] → 10
+ // i=1: [10,20] → 30
+ // i=2: [10,20,30] → 60
+ // i=3: [30,40] → 70
+ // i=4: [50] → 50
+ expect(result).toEqual([10, 30, 60, 70, 50]);
+ });
+
+ test("empty array returns empty result", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 3 });
+ const result = applyIndexer(idx, [], sum);
+ expect(result).toEqual([]);
+ });
+
+ test("all NaN values with minPeriods=1 → all null", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 2 });
+ const result = applyIndexer(idx, [NaN, NaN, NaN], sum, 1);
+ expect(result).toEqual([null, null, null]);
+ });
+
+ test("undefined values treated as missing", () => {
+ const idx = new FixedForwardWindowIndexer({ windowSize: 2 });
+ const result = applyIndexer(idx, [1, undefined, 3], sum);
+ expect(result[0]).toBe(1);
+ expect(result[1]).toBe(3);
+ expect(result[2]).toBe(3);
+ });
+});
+
+// ─── integration: custom subclass ─────────────────────────────────────────────
+
+describe("Custom BaseIndexer subclass", () => {
+ // Expanding trailing window (like Expanding but as an indexer)
+ class ExpandingIndexer extends BaseIndexer {
+ getWindowBounds(numValues: number): WindowBounds {
+ const start = new Int32Array(numValues);
+ const end = new Int32Array(numValues);
+ for (let i = 0; i < numValues; i++) {
+ start[i] = 0;
+ end[i] = i + 1;
+ }
+ return [start, end];
+ }
+ }
+
+ test("custom expanding indexer sums correctly", () => {
+ const idx = new ExpandingIndexer();
+ const result = applyIndexer(idx, [1, 2, 3, 4, 5], (nums) =>
+ nums.reduce((a, b) => a + b, 0),
+ );
+ expect(result).toEqual([1, 3, 6, 10, 15]);
+ });
+});