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/options.html b/playground/options.html
new file mode 100644
index 00000000..c3e74cf0
--- /dev/null
+++ b/playground/options.html
@@ -0,0 +1,281 @@
+
+
+
+
+
+
tsb — pd.options system
+
+
+
+
+
+
+
Initializing playground…
+
+
+
← Back to roadmap
+
pd.options system
+
The options system mirrors pandas.get_option /
+ pandas.set_option. It manages display, mode, and compute settings with full
+ validation support.
+
+
+
1 — getOption / setOption / resetOption
+
Read, write, and restore option values by dot-separated key.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
2 — describeOption
+
Pretty-print documentation for one or all options.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
3 — optionContext (scoped override)
+
Temporarily override options and restore them with enter() / exit().
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
4 — options proxy
+
Access options via a deeply-nested proxy object for ergonomic reads and writes.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
5 — registerOption (custom option)
+
Extend the registry with application-specific options, including custom validators.
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
+
+
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/api_types.ts b/src/core/api_types.ts
index 12f53671..2820e218 100644
--- a/src/core/api_types.ts
+++ b/src/core/api_types.ts
@@ -73,8 +73,9 @@ export function isScalar(val: unknown): boolean {
}
/**
- * Return `true` if `val` is "list-like" — i.e. iterable (but not a string)
- * or has a non-negative integer `length` property.
+ * Return `true` if `val` is "list-like" — i.e. iterable or has a
+ * non-negative integer `length` property. Strings are included, matching
+ * the behaviour of `pandas.api.types.is_list_like`.
*
* Mirrors `pandas.api.types.is_list_like`.
*
@@ -82,7 +83,7 @@ export function isScalar(val: unknown): boolean {
* ```ts
* isListLike([1, 2, 3]); // true
* isListLike(new Set([1])); // true
- * isListLike("abc"); // false (strings excluded)
+ * isListLike("abc"); // true
* isListLike(42); // false
* isListLike({ a: 1 }); // false
* ```
@@ -92,7 +93,7 @@ export function isListLike(val: unknown): boolean {
return false;
}
if (typeof val === "string") {
- return false;
+ return true;
}
// Has Symbol.iterator and is not a plain number/boolean/bigint/symbol
if (
diff --git a/src/core/date_range.ts b/src/core/date_range.ts
index 4560db94..f893ae81 100644
--- a/src/core/date_range.ts
+++ b/src/core/date_range.ts
@@ -530,18 +530,16 @@ export function bdate_range(options: DateRangeOptions): DatetimeIndex {
const MAX_ITER = 1_000_000;
-function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): DatetimeIndex {
- const { start, end, periods, normalize = false, name = null } = options;
- const freq = options.freq ?? defaultFreq;
- const offset = freqToOffset(freq);
-
+function resolveBoundDates(
+ start: string | Date | undefined,
+ end: string | Date | undefined,
+ normalize: boolean,
+): { startDate: Date | null; endDate: Date | null } {
if (start === undefined && end === undefined) {
throw new Error("date_range: at least one of 'start' or 'end' must be provided");
}
-
let startDate = start !== undefined ? toDate(start) : null;
let endDate = end !== undefined ? toDate(end) : null;
-
if (normalize) {
if (startDate !== null) {
startDate = normDate(startDate);
@@ -550,19 +548,33 @@ function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): Date
endDate = normDate(endDate);
}
}
+ return { startDate, endDate };
+}
- let dates: Date[];
-
+function selectDates(
+ startDate: Date | null,
+ endDate: Date | null,
+ periods: number | undefined,
+ offset: DateOffset,
+): Date[] {
if (startDate !== null && endDate !== null && periods === undefined) {
- dates = rangeStartEnd(startDate, endDate, offset);
- } else if (startDate !== null && periods !== undefined) {
- dates = rangeStartPeriods(startDate, periods, offset);
- } else if (endDate !== null && periods !== undefined) {
- dates = rangeEndPeriods(endDate, periods, offset);
- } else {
- throw new Error("date_range: provide at least two of 'start', 'end', 'periods'");
+ return rangeStartEnd(startDate, endDate, offset);
+ }
+ if (startDate !== null && periods !== undefined) {
+ return rangeStartPeriods(startDate, periods, offset);
}
+ if (endDate !== null && periods !== undefined) {
+ return rangeEndPeriods(endDate, periods, offset);
+ }
+ throw new Error("date_range: provide at least two of 'start', 'end', 'periods'");
+}
+function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): DatetimeIndex {
+ const { start, end, periods, normalize = false, name = null } = options;
+ const freq = options.freq ?? defaultFreq;
+ const offset = freqToOffset(freq);
+ const { startDate, endDate } = resolveBoundDates(start, end, normalize);
+ const dates = selectDates(startDate, endDate, periods, offset);
return DatetimeIndex.fromDates(dates, name);
}
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/natsort.ts b/src/core/natsort.ts
index d0112d3e..260d10d6 100644
--- a/src/core/natsort.ts
+++ b/src/core/natsort.ts
@@ -88,7 +88,13 @@ function cmpTokens(a: Token, b: Token, ignoreCase: boolean): number {
if (typeof a === "string" && typeof b === "string") {
const la = ignoreCase ? a.toLowerCase() : a;
const lb = ignoreCase ? b.toLowerCase() : b;
- return la < lb ? -1 : la > lb ? 1 : 0;
+ if (la < lb) {
+ return -1;
+ }
+ if (la > lb) {
+ return 1;
+ }
+ return 0;
}
// Mixed: digit tokens sort before string tokens
return typeof a === "number" ? -1 : 1;
@@ -205,6 +211,27 @@ export function natSortKey(
return tokens.map((t) => (typeof t === "string" ? t.toLowerCase() : t));
}
+/** Compare two pre-tokenised keys for use inside `natArgSort`'s sort callback. */
+function cmpTokenArrays(ta: readonly Token[], tb: readonly Token[], reverse: boolean): number {
+ const len = Math.min(ta.length, tb.length);
+ for (let k = 0; k < len; k++) {
+ const taToken = ta[k];
+ const tbToken = tb[k];
+ if (taToken === undefined || tbToken === undefined) {
+ break;
+ }
+ const c = cmpTokens(taToken, tbToken, false); // already case-folded
+ if (c !== 0) {
+ return reverse ? -c : c;
+ }
+ }
+ const lc = ta.length - tb.length;
+ if (lc === 0) {
+ return 0;
+ }
+ return reverse ? -lc : lc;
+}
+
/**
* Return the integer permutation that would sort `arr` in natural order.
*
@@ -229,23 +256,7 @@ export function natArgSort(arr: readonly string[], options: NatSortOptions = {})
if (ta === undefined || tb === undefined) {
throw new RangeError("natArgSort: index out of bounds");
}
- const len = Math.min(ta.length, tb.length);
- for (let k = 0; k < len; k++) {
- const taToken = ta[k];
- const tbToken = tb[k];
- if (taToken === undefined || tbToken === undefined) {
- break;
- }
- const c = cmpTokens(taToken, tbToken, false); // already case-folded
- if (c !== 0) {
- return reverse ? -c : c;
- }
- }
- const lc = ta.length - tb.length;
- if (lc === 0) {
- return 0;
- }
- return reverse ? -lc : lc;
+ return cmpTokenArrays(ta, tb, reverse);
});
return indices;
}
diff --git a/src/core/options.ts b/src/core/options.ts
new file mode 100644
index 00000000..628b5cce
--- /dev/null
+++ b/src/core/options.ts
@@ -0,0 +1,349 @@
+/**
+ * options — pandas-compatible options system for tsb.
+ *
+ * Mirrors `pandas.core.config_init` / `pandas.get_option` / `pandas.set_option` etc.
+ *
+ * Supports:
+ * - `getOption(key)` / `setOption(key, val)` / `resetOption(key)`
+ * - `describeOption(key?)` — pretty-print option documentation
+ * - `optionContext(key, val, ...)` — context-manager-like scoped override (async-safe via manual enter/exit)
+ * - `registerOption(key, defaultVal, doc?, validator?)` — extend the registry
+ * - `options` — a deep Proxy object providing `options.display.maxRows` style access
+ *
+ * @example
+ * ```ts
+ * import { getOption, setOption, resetOption, options } from "tsb";
+ * getOption("display.max_rows"); // 60
+ * setOption("display.max_rows", 20);
+ * options.display.max_rows; // 20
+ * resetOption("display.max_rows");
+ * ```
+ *
+ * @module
+ */
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+/** Primitive option value types. */
+export type OptionValue = string | number | boolean | null;
+
+/** Validator function — returns an error string on invalid value, or undefined. */
+export type OptionValidator = (val: OptionValue) => string | undefined;
+
+interface OptionEntry {
+ defaultValue: OptionValue;
+ currentValue: OptionValue;
+ doc: string;
+ validator?: OptionValidator;
+}
+
+// ─── Internal registry ────────────────────────────────────────────────────────
+
+const _registry = new Map();
+
+function _normalizeKey(key: string): string {
+ return key.toLowerCase().replace(/-/g, "_");
+}
+
+function _lookupEntry(key: string): OptionEntry {
+ const k = _normalizeKey(key);
+ const entry = _registry.get(k);
+ if (entry === undefined) {
+ throw new Error(
+ `No such option: ${key}. Available options:\n${[..._registry.keys()].sort().join("\n")}`,
+ );
+ }
+ return entry;
+}
+
+// ─── Public API ───────────────────────────────────────────────────────────────
+
+/**
+ * Register a new option with a key, default value, optional documentation, and
+ * optional validator. Throws if the key is already registered.
+ */
+export function registerOption(
+ key: string,
+ defaultValue: OptionValue,
+ doc = "",
+ validator?: OptionValidator,
+): void {
+ const k = _normalizeKey(key);
+ if (_registry.has(k)) {
+ throw new Error(`Option already registered: ${key}`);
+ }
+ const entry: OptionEntry = { defaultValue, currentValue: defaultValue, doc };
+ if (validator !== undefined) {
+ entry.validator = validator;
+ }
+ _registry.set(k, entry);
+}
+
+/**
+ * Retrieve the current value of an option by its dot-separated key.
+ * Raises if the key does not exist.
+ */
+export function getOption(key: string): OptionValue {
+ return _lookupEntry(key).currentValue;
+}
+
+/**
+ * Set an option value. Validates with the registered validator, if any.
+ * Raises if the key does not exist or the value is invalid.
+ */
+export function setOption(key: string, value: OptionValue): void {
+ const entry = _lookupEntry(key);
+ if (entry.validator !== undefined) {
+ const err = entry.validator(value);
+ if (err !== undefined) {
+ throw new Error(`Invalid value for option '${key}': ${err}`);
+ }
+ }
+ entry.currentValue = value;
+}
+
+/**
+ * Reset one or all options to their default values.
+ * Pass `"all"` to reset every option.
+ */
+export function resetOption(key: string): void {
+ if (_normalizeKey(key) === "all") {
+ for (const entry of _registry.values()) {
+ entry.currentValue = entry.defaultValue;
+ }
+ return;
+ }
+ const entry = _lookupEntry(key);
+ entry.currentValue = entry.defaultValue;
+}
+
+/**
+ * Return a formatted string describing one or all options.
+ * Pass a prefix to narrow results (e.g., `"display"`).
+ */
+export function describeOption(key?: string): string {
+ const prefix = key !== undefined ? _normalizeKey(key) : "";
+ const matches: string[] = [];
+ for (const [k, entry] of _registry.entries()) {
+ if (prefix === "" || k === prefix || k.startsWith(`${prefix}.`)) {
+ matches.push(
+ `${k}\n Default : ${String(entry.defaultValue)}\n Current : ${String(entry.currentValue)}\n ${entry.doc}`,
+ );
+ }
+ }
+ if (matches.length === 0) {
+ throw new Error(`No option matching: ${key ?? "(all)"}`);
+ }
+ return matches.join("\n\n");
+}
+
+// ─── Option Context ───────────────────────────────────────────────────────────
+
+/** A scoped override token returned by {@link optionContext}. */
+export interface OptionContextToken {
+ /** Apply the overrides (call before the scoped block). */
+ enter(): void;
+ /** Restore the previous values (call after the scoped block). */
+ exit(): void;
+}
+
+/**
+ * Create a scoped override context for one or more options.
+ * Pass alternating key–value pairs.
+ *
+ * @example
+ * ```ts
+ * const ctx = optionContext("display.max_rows", 5, "display.max_columns", 3);
+ * ctx.enter();
+ * // ... code that uses options ...
+ * ctx.exit();
+ * ```
+ */
+export function optionContext(
+ ...keyValuePairs: readonly (OptionValue | string)[]
+): OptionContextToken {
+ if (keyValuePairs.length % 2 !== 0) {
+ throw new Error("optionContext requires pairs of (key, value) arguments");
+ }
+ const pairs: Array<{ key: string; value: OptionValue }> = [];
+ for (let i = 0; i < keyValuePairs.length; i += 2) {
+ const k = keyValuePairs[i];
+ const v = keyValuePairs[i + 1];
+ if (typeof k !== "string") {
+ throw new Error("optionContext keys must be strings");
+ }
+ pairs.push({ key: k, value: v as OptionValue });
+ }
+
+ const saved = new Map();
+
+ return {
+ enter(): void {
+ for (const { key, value } of pairs) {
+ saved.set(key, getOption(key));
+ setOption(key, value);
+ }
+ },
+ exit(): void {
+ for (const { key } of [...pairs].reverse()) {
+ const prev = saved.get(key);
+ if (prev !== undefined) {
+ setOption(key, prev);
+ }
+ }
+ saved.clear();
+ },
+ };
+}
+
+// ─── `options` Proxy ──────────────────────────────────────────────────────────
+
+export interface OptionsProxy extends Record {}
+
+function _makeProxy(prefix: string): OptionsProxy {
+ return new Proxy({} as OptionsProxy, {
+ get(_target, prop: string | symbol): OptionsProxy | OptionValue {
+ if (typeof prop !== "string") {
+ return undefined as unknown as OptionValue;
+ }
+ const key = prefix !== "" ? `${prefix}.${prop}` : prop;
+ // If there is a direct match, return its value
+ const k = _normalizeKey(key);
+ if (_registry.has(k)) {
+ return _registry.get(k)!.currentValue;
+ }
+ // Otherwise return a nested proxy for deeper access
+ return _makeProxy(k);
+ },
+ set(_target, prop: string | symbol, value: unknown): boolean {
+ if (typeof prop !== "string") {
+ return false;
+ }
+ const key = prefix !== "" ? `${prefix}.${prop}` : prop;
+ setOption(key, value as OptionValue);
+ return true;
+ },
+ });
+}
+
+/**
+ * A deeply-nested proxy object that mirrors the options registry.
+ * Supports both read and write access.
+ *
+ * @example
+ * ```ts
+ * options.display.max_rows; // 60
+ * options.display.max_rows = 20;
+ * ```
+ */
+export const options: OptionsProxy = _makeProxy("");
+
+// ─── Built-in options ─────────────────────────────────────────────────────────
+
+function _posInt(name: string): OptionValidator {
+ return (v): string | undefined =>
+ typeof v === "number" && Number.isInteger(v) && v >= 0
+ ? undefined
+ : `${name} must be a non-negative integer`;
+}
+
+function _bool(name: string): OptionValidator {
+ return (v): string | undefined =>
+ typeof v === "boolean" ? undefined : `${name} must be a boolean`;
+}
+
+function _oneOf(name: string, choices: readonly OptionValue[]): OptionValidator {
+ return (v): string | undefined =>
+ choices.includes(v) ? undefined : `${name} must be one of: ${choices.map(String).join(", ")}`;
+}
+
+// display.*
+registerOption(
+ "display.max_rows",
+ 60,
+ "Maximum number of rows to display.",
+ _posInt("display.max_rows"),
+);
+registerOption(
+ "display.min_rows",
+ 10,
+ "Minimum rows displayed when the frame is truncated.",
+ _posInt("display.min_rows"),
+);
+registerOption(
+ "display.max_columns",
+ 20,
+ "Maximum number of columns to display.",
+ _posInt("display.max_columns"),
+);
+registerOption(
+ "display.max_colwidth",
+ 50,
+ "Maximum width of column values (characters) before truncation.",
+ _posInt("display.max_colwidth"),
+);
+registerOption("display.width", 80, "Terminal width used for wrapping.", _posInt("display.width"));
+registerOption(
+ "display.precision",
+ 6,
+ "Floating-point display precision (significant digits).",
+ _posInt("display.precision"),
+);
+registerOption(
+ "display.show_dimensions",
+ true,
+ "Whether to show DataFrame dimensions at the end of repr.",
+ _bool("display.show_dimensions"),
+);
+registerOption(
+ "display.unicode.east_asian_width",
+ false,
+ "Whether to account for east-Asian character widths.",
+ _bool("display.unicode.east_asian_width"),
+);
+registerOption(
+ "display.float_format",
+ null,
+ "Callable used to format floating-point values; null means use default repr.",
+);
+registerOption(
+ "display.colheader_justify",
+ "right",
+ "Justify column headers: 'left' or 'right'.",
+ _oneOf("display.colheader_justify", ["left", "right"]),
+);
+
+// mode.*
+registerOption(
+ "mode.chained_assignment",
+ "warn",
+ "Controls SettingWithCopyWarning: 'raise', 'warn', or null.",
+ _oneOf("mode.chained_assignment", ["raise", "warn", null]),
+);
+registerOption(
+ "mode.dtype_backend",
+ "numpy",
+ "Default dtype backend: 'numpy' or 'arrow'.",
+ _oneOf("mode.dtype_backend", ["numpy", "arrow"]),
+);
+registerOption("mode.use_inf_as_na", false, "Treat infinity as NA.", _bool("mode.use_inf_as_na"));
+registerOption(
+ "mode.copy_on_write",
+ false,
+ "Enable copy-on-write semantics.",
+ _bool("mode.copy_on_write"),
+);
+
+// compute.*
+registerOption(
+ "compute.use_bottleneck",
+ false,
+ "Use the bottleneck library for numeric operations.",
+ _bool("compute.use_bottleneck"),
+);
+registerOption(
+ "compute.use_numexpr",
+ false,
+ "Use numexpr for large array eval().",
+ _bool("compute.use_numexpr"),
+);
diff --git a/src/core/pd_api.ts b/src/core/pd_api.ts
new file mode 100644
index 00000000..0964d2e0
--- /dev/null
+++ b/src/core/pd_api.ts
@@ -0,0 +1,108 @@
+/**
+ * pd_api — the `pd.api` namespace object, mirroring `pandas.api`.
+ *
+ * Provides `api.types` (type-checking predicates) sub-namespace, analogous to
+ * `pandas.api.types`.
+ *
+ * @example
+ * ```ts
+ * import { api } from "tsb";
+ * api.types.isScalar(42); // true
+ * api.types.isNumericDtype("float64"); // true
+ * api.types.isListLike([1, 2, 3]); // true
+ * ```
+ *
+ * @module
+ */
+
+import {
+ isArrayLike,
+ isBigInt,
+ isBool,
+ isBoolDtype,
+ isCategoricalDtype,
+ isComplexDtype,
+ isDate,
+ isDatetimeDtype,
+ isDictLike,
+ isExtensionArrayDtype,
+ isFloat,
+ isFloatDtype,
+ isHashable,
+ isInteger,
+ isIntegerDtype,
+ isIntervalDtype,
+ isIterator,
+ isListLike,
+ isMissing,
+ isNumber,
+ isNumericDtype,
+ isObjectDtype,
+ isPeriodDtype,
+ isScalar,
+ isSignedIntegerDtype,
+ isStringDtype,
+ isTimedeltaDtype,
+ isUnsignedIntegerDtype,
+} from "./api_types.ts";
+
+// ─── api.types ────────────────────────────────────────────────────────────────
+
+/**
+ * The `api.types` sub-namespace — mirrors `pandas.api.types`.
+ *
+ * Contains predicates for both runtime values and dtypes.
+ */
+export const apiTypes = {
+ // Value-level predicates
+ isScalar,
+ isListLike,
+ isArrayLike,
+ isDictLike,
+ isIterator,
+ isNumber,
+ isBool,
+ isFloat,
+ isInteger,
+ isBigInt,
+ isMissing,
+ isHashable,
+ isDate,
+
+ // Dtype-level predicates
+ isNumericDtype,
+ isIntegerDtype,
+ isSignedIntegerDtype,
+ isUnsignedIntegerDtype,
+ isFloatDtype,
+ isBoolDtype,
+ isStringDtype,
+ isDatetimeDtype,
+ isTimedeltaDtype,
+ isCategoricalDtype,
+ isObjectDtype,
+ isComplexDtype,
+ isExtensionArrayDtype,
+ isPeriodDtype,
+ isIntervalDtype,
+} as const;
+
+export type ApiTypes = typeof apiTypes;
+
+// ─── api namespace ────────────────────────────────────────────────────────────
+
+/**
+ * The top-level `api` namespace object, mirroring `pandas.api`.
+ *
+ * @example
+ * ```ts
+ * import { api } from "tsb";
+ * api.types.isScalar(42);
+ * ```
+ */
+export const api = {
+ /** Type-checking predicates — mirrors `pandas.api.types`. */
+ types: apiTypes,
+} as const;
+
+export type Api = typeof api;
diff --git a/src/core/series.ts b/src/core/series.ts
index b4328084..1b39cffc 100644
--- a/src/core/series.ts
+++ b/src/core/series.ts
@@ -1083,11 +1083,111 @@ 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,
});
@@ -1214,6 +1314,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..8730b7c1
--- /dev/null
+++ b/src/groupby/grouper.ts
@@ -0,0 +1,191 @@
+/**
+ * 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..4738b8d9 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,24 @@ 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";
+// pd.options system
+export {
+ getOption,
+ setOption,
+ resetOption,
+ describeOption,
+ optionContext,
+ registerOption,
+ options,
+} from "./core/options.ts";
+export type {
+ OptionValue,
+ OptionValidator,
+ OptionContextToken,
+ OptionsProxy,
+} from "./core/options.ts";
+// pd.api namespace
+export { api, apiTypes } from "./core/pd_api.ts";
+export type { Api, ApiTypes } from "./core/pd_api.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-e2e/playground-cells.test.ts b/tests-e2e/playground-cells.test.ts
index 9d8df8a5..481456fa 100644
--- a/tests-e2e/playground-cells.test.ts
+++ b/tests-e2e/playground-cells.test.ts
@@ -44,7 +44,7 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
-import { type Browser, type BrowserContext, chromium } from "playwright";
+import { type Browser, type BrowserContext, type Locator, type Page, chromium } from "playwright";
const PROJECT_ROOT = join(import.meta.dir, "..");
const PLAYGROUND_DIR = join(PROJECT_ROOT, "playground");
@@ -97,8 +97,8 @@ async function startPlaygroundServer(): Promise {
minify: true,
});
if (!buildResult.success) {
- for (const log of buildResult.logs) console.error(log);
- throw new Error("Failed to build tsb browser bundle");
+ const buildLogs = buildResult.logs.map((l) => String(l)).join("\n");
+ throw new Error(`Failed to build tsb browser bundle:\n${buildLogs}`);
}
const tsSrc = join(PROJECT_ROOT, "node_modules", "typescript", "lib", "typescript.js");
await Bun.write(join(PLAYGROUND_DIR, "dist", "typescript.js"), Bun.file(tsSrc));
@@ -126,7 +126,42 @@ async function startPlaygroundServer(): Promise {
return new Response("Not found", { status: 404 });
},
});
- return { kill: () => server.stop(true) };
+ return {
+ kill: (): void => {
+ server.stop(true);
+ },
+ };
+}
+
+function classifyOutput(text: string, cls: string): CellOutcome["reason"] | null {
+ const trimmed = text.trim();
+ if (cls.includes("error") || trimmed.startsWith("❌")) {
+ return `error: ${trimmed.slice(0, 200)}`;
+ }
+ if (trimmed === "" || trimmed.startsWith("(no output") || trimmed.startsWith("Click ▶")) {
+ return "no output produced";
+ }
+ return null; // success
+}
+
+async function runCell(block: Locator, cellIndex: number, page: Page): Promise {
+ try {
+ await block.locator(".playground-run").click();
+ // Output is updated synchronously inside the click handler, but give
+ // the event loop a tick for the DOM update to flush.
+ await page.waitForTimeout(50);
+ const outputLoc = block.locator(".playground-output").first();
+ const text = (await outputLoc.textContent()) ?? "";
+ const cls = (await outputLoc.getAttribute("class")) ?? "";
+ const reason = classifyOutput(text, cls);
+ if (reason !== null) {
+ return { cellIndex, ok: false, reason };
+ }
+ return { cellIndex, ok: true, reason: "" };
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ return { cellIndex, ok: false, reason: `playwright error: ${msg}` };
+ }
}
async function executePageCells(ctx: BrowserContext, file: string): Promise {
@@ -151,36 +186,10 @@ async function executePageCells(ctx: BrowserContext, file: string): Promise 0) {
@@ -196,6 +205,48 @@ async function executePageCells(ctx: BrowserContext, file: string): Promise 0) {
+ messages.push(
+ `${unexpectedFailures.length} regression(s) in ${file}:\n - ${unexpectedFailures.join("\n - ")}`,
+ );
+ }
+ if (unexpectedPasses.length > 0) {
+ messages.push(
+ `${unexpectedPasses.length} cell(s) in ${file} now PASS but are still listed in tests-e2e/known-failures.json — please remove them: [${unexpectedPasses.join(", ")}]`,
+ );
+ }
+ if (messages.length > 0) {
+ throw new Error(messages.join("\n\n"));
+ }
+}
+
let server: ServerHandle | null = null;
let browser: Browser | null = null;
let context: BrowserContext | null = null;
@@ -216,7 +267,9 @@ beforeAll(async () => {
while (nextIdx < files.length) {
const i = nextIdx++;
const file = files[i];
- if (!file || !context) continue;
+ if (!(file && context)) {
+ continue;
+ }
const outcomes = await executePageCells(context, file);
allOutcomes.set(file, outcomes);
}
@@ -225,9 +278,15 @@ beforeAll(async () => {
}, 600_000);
afterAll(async () => {
- if (context) await context.close();
- if (browser) await browser.close();
- if (server) server.kill();
+ if (context) {
+ await context.close();
+ }
+ if (browser) {
+ await browser.close();
+ }
+ if (server) {
+ server.kill();
+ }
});
describe("playground page execution", () => {
@@ -237,40 +296,7 @@ describe("playground page execution", () => {
for (const file of files) {
it(`every cell on ${file} produces non-error, non-empty output`, () => {
- const outcomes = allOutcomes.get(file);
- expect(outcomes, `no outcomes recorded for ${file}`).toBeDefined();
- const allowedCells = new Set(knownFailures[file] ?? []);
- const unexpectedFailures: string[] = [];
- const unexpectedPasses: number[] = [];
-
- for (const o of outcomes ?? []) {
- if (o.cellIndex === 0) {
- // Page-level failure (e.g., uncaught page error) — never allowlist.
- unexpectedFailures.push(`page-level: ${o.reason}`);
- continue;
- }
- const inAllowlist = allowedCells.has(o.cellIndex);
- if (!o.ok && !inAllowlist) {
- unexpectedFailures.push(`cell ${o.cellIndex}: ${o.reason}`);
- } else if (o.ok && inAllowlist) {
- unexpectedPasses.push(o.cellIndex);
- }
- }
-
- const messages: string[] = [];
- if (unexpectedFailures.length > 0) {
- messages.push(
- `${unexpectedFailures.length} regression(s) in ${file}:\n - ${unexpectedFailures.join("\n - ")}`,
- );
- }
- if (unexpectedPasses.length > 0) {
- messages.push(
- `${unexpectedPasses.length} cell(s) in ${file} now PASS but are still listed in tests-e2e/known-failures.json — please remove them: [${unexpectedPasses.join(", ")}]`,
- );
- }
- if (messages.length > 0) {
- throw new Error(messages.join("\n\n"));
- }
+ assertPageOutcomes(file, allOutcomes.get(file), knownFailures);
});
}
});
diff --git a/tests/core/api_types.test.ts b/tests/core/api_types.test.ts
index 94a7a6cd..d01e4ce8 100644
--- a/tests/core/api_types.test.ts
+++ b/tests/core/api_types.test.ts
@@ -90,9 +90,9 @@ describe("isListLike", () => {
expect(isListLike(new Map())).toBe(true);
});
- it("returns false for strings (excluded)", () => {
- expect(isListLike("abc")).toBe(false);
- expect(isListLike("")).toBe(false);
+ it("returns true for strings", () => {
+ expect(isListLike("abc")).toBe(true);
+ expect(isListLike("")).toBe(true);
});
it("returns false for numbers and booleans", () => {
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/options.test.ts b/tests/core/options.test.ts
new file mode 100644
index 00000000..4161f375
--- /dev/null
+++ b/tests/core/options.test.ts
@@ -0,0 +1,184 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import {
+ describeOption,
+ getOption,
+ optionContext,
+ options,
+ registerOption,
+ resetOption,
+ setOption,
+} from "../../src/index.ts";
+
+describe("options system", () => {
+ // Reset before each test to avoid side effects
+ beforeEach(() => {
+ resetOption("all");
+ });
+
+ // ─── getOption ───────────────────────────────────────────────────────────
+ describe("getOption", () => {
+ test("returns default value for display.max_rows", () => {
+ expect(getOption("display.max_rows")).toBe(60);
+ });
+
+ test("returns default value for display.precision", () => {
+ expect(getOption("display.precision")).toBe(6);
+ });
+
+ test("returns default value for mode.use_inf_as_na", () => {
+ expect(getOption("mode.use_inf_as_na")).toBe(false);
+ });
+
+ test("throws on unknown key", () => {
+ expect(() => getOption("nonexistent.key")).toThrow(/No such option/);
+ });
+
+ test("is case-insensitive", () => {
+ expect(getOption("Display.Max_Rows")).toBe(60);
+ });
+ });
+
+ // ─── setOption ───────────────────────────────────────────────────────────
+ describe("setOption", () => {
+ test("sets a numeric option", () => {
+ setOption("display.max_rows", 100);
+ expect(getOption("display.max_rows")).toBe(100);
+ });
+
+ test("sets a boolean option", () => {
+ setOption("mode.use_inf_as_na", true);
+ expect(getOption("mode.use_inf_as_na")).toBe(true);
+ });
+
+ test("sets a string option", () => {
+ setOption("mode.dtype_backend", "arrow");
+ expect(getOption("mode.dtype_backend")).toBe("arrow");
+ });
+
+ test("throws on invalid value (validator)", () => {
+ expect(() => setOption("display.max_rows", -1)).toThrow(/must be a non-negative integer/);
+ });
+
+ test("throws on invalid enum value", () => {
+ expect(() => setOption("mode.chained_assignment", "invalid")).toThrow(/must be one of/);
+ });
+
+ test("throws on unknown key", () => {
+ expect(() => setOption("display.unknown", 5)).toThrow(/No such option/);
+ });
+ });
+
+ // ─── resetOption ─────────────────────────────────────────────────────────
+ describe("resetOption", () => {
+ test("resets single option to default", () => {
+ setOption("display.max_rows", 999);
+ resetOption("display.max_rows");
+ expect(getOption("display.max_rows")).toBe(60);
+ });
+
+ test("resetOption('all') resets all options", () => {
+ setOption("display.max_rows", 999);
+ setOption("display.precision", 99);
+ resetOption("all");
+ expect(getOption("display.max_rows")).toBe(60);
+ expect(getOption("display.precision")).toBe(6);
+ });
+
+ test("throws on unknown key", () => {
+ expect(() => resetOption("completely.unknown")).toThrow(/No such option/);
+ });
+ });
+
+ // ─── describeOption ──────────────────────────────────────────────────────
+ describe("describeOption", () => {
+ test("describes a single option", () => {
+ const desc = describeOption("display.max_rows");
+ expect(desc).toContain("display.max_rows");
+ expect(desc).toContain("Default");
+ expect(desc).toContain("60");
+ });
+
+ test("describes all display options when given prefix", () => {
+ const desc = describeOption("display");
+ expect(desc).toContain("display.max_rows");
+ expect(desc).toContain("display.precision");
+ });
+
+ test("describes all options when called with no arg", () => {
+ const desc = describeOption();
+ expect(desc).toContain("display.max_rows");
+ expect(desc).toContain("mode.use_inf_as_na");
+ expect(desc).toContain("compute.use_bottleneck");
+ });
+
+ test("throws on unmatched prefix", () => {
+ expect(() => describeOption("nonexistent")).toThrow(/No option matching/);
+ });
+ });
+
+ // ─── optionContext ────────────────────────────────────────────────────────
+ describe("optionContext", () => {
+ test("overrides option within context, restores after exit", () => {
+ const ctx = optionContext("display.max_rows", 5);
+ ctx.enter();
+ expect(getOption("display.max_rows")).toBe(5);
+ ctx.exit();
+ expect(getOption("display.max_rows")).toBe(60);
+ });
+
+ test("supports multiple key-value pairs", () => {
+ const ctx = optionContext("display.max_rows", 5, "display.precision", 2);
+ ctx.enter();
+ expect(getOption("display.max_rows")).toBe(5);
+ expect(getOption("display.precision")).toBe(2);
+ ctx.exit();
+ expect(getOption("display.max_rows")).toBe(60);
+ expect(getOption("display.precision")).toBe(6);
+ });
+
+ test("restores even if override raises", () => {
+ const ctx = optionContext("display.max_rows", 20);
+ ctx.enter();
+ expect(getOption("display.max_rows")).toBe(20);
+ ctx.exit();
+ expect(getOption("display.max_rows")).toBe(60);
+ });
+
+ test("throws on odd argument count", () => {
+ expect(() => optionContext("display.max_rows")).toThrow(/pairs/);
+ });
+ });
+
+ // ─── registerOption ───────────────────────────────────────────────────────
+ describe("registerOption", () => {
+ test("registers a new option and allows get/set/reset", () => {
+ registerOption("test.custom_opt", 42, "A custom test option.");
+ expect(getOption("test.custom_opt")).toBe(42);
+ setOption("test.custom_opt", 99);
+ expect(getOption("test.custom_opt")).toBe(99);
+ resetOption("test.custom_opt");
+ expect(getOption("test.custom_opt")).toBe(42);
+ });
+
+ test("throws if key already registered", () => {
+ // display.max_rows is already registered
+ expect(() => registerOption("display.max_rows", 0)).toThrow(/already registered/);
+ });
+ });
+
+ // ─── options proxy ────────────────────────────────────────────────────────
+ describe("options proxy", () => {
+ test("reads option value via proxy", () => {
+ const display = options["display"];
+ if (typeof display !== "object" || display == null) throw new Error("expected nested proxy");
+ expect(display["max_rows"]).toBe(60);
+ });
+
+ test("writes option value via proxy", () => {
+ const display = options["display"];
+ if (typeof display !== "object" || display == null) throw new Error("expected nested proxy");
+ display["max_rows"] = 77;
+ expect(getOption("display.max_rows")).toBe(77);
+ });
+ });
+});
diff --git a/tests/core/pd_api.test.ts b/tests/core/pd_api.test.ts
new file mode 100644
index 00000000..775de83d
--- /dev/null
+++ b/tests/core/pd_api.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, test } from "bun:test";
+import { api, apiTypes } from "../../src/index.ts";
+import { Dtype } from "../../src/index.ts";
+
+describe("api namespace", () => {
+ describe("api.types value predicates", () => {
+ test("isScalar returns true for primitives", () => {
+ expect(api.types.isScalar(42)).toBe(true);
+ expect(api.types.isScalar("hello")).toBe(true);
+ expect(api.types.isScalar(true)).toBe(true);
+ expect(api.types.isScalar(null)).toBe(true);
+ });
+
+ test("isScalar returns false for arrays", () => {
+ expect(api.types.isScalar([1, 2, 3])).toBe(false);
+ });
+
+ test("isListLike returns true for arrays and strings", () => {
+ expect(api.types.isListLike([1, 2])).toBe(true);
+ expect(api.types.isListLike("abc")).toBe(true);
+ });
+
+ test("isListLike returns false for scalars", () => {
+ expect(api.types.isListLike(42)).toBe(false);
+ });
+
+ test("isDictLike returns true for plain objects", () => {
+ expect(api.types.isDictLike({ a: 1 })).toBe(true);
+ });
+
+ test("isDictLike returns false for arrays", () => {
+ expect(api.types.isDictLike([1, 2])).toBe(false);
+ });
+
+ test("isNumber", () => {
+ expect(api.types.isNumber(42)).toBe(true);
+ expect(api.types.isNumber("42")).toBe(false);
+ });
+
+ test("isBool", () => {
+ expect(api.types.isBool(true)).toBe(true);
+ expect(api.types.isBool(1)).toBe(false);
+ });
+
+ test("isFloat", () => {
+ expect(api.types.isFloat(3.14)).toBe(true);
+ expect(api.types.isFloat(3)).toBe(false);
+ });
+
+ test("isInteger", () => {
+ expect(api.types.isInteger(3)).toBe(true);
+ expect(api.types.isInteger(3.14)).toBe(false);
+ });
+
+ test("isMissing returns true for null/undefined/NaN", () => {
+ expect(api.types.isMissing(null)).toBe(true);
+ expect(api.types.isMissing(undefined)).toBe(true);
+ expect(api.types.isMissing(Number.NaN)).toBe(true);
+ expect(api.types.isMissing(0)).toBe(false);
+ });
+ });
+
+ describe("api.types dtype predicates", () => {
+ test("isNumericDtype", () => {
+ expect(api.types.isNumericDtype(Dtype.float64)).toBe(true);
+ expect(api.types.isNumericDtype(Dtype.int32)).toBe(true);
+ expect(api.types.isNumericDtype(Dtype.string)).toBe(false);
+ });
+
+ test("isIntegerDtype", () => {
+ expect(api.types.isIntegerDtype(Dtype.int64)).toBe(true);
+ expect(api.types.isIntegerDtype(Dtype.float64)).toBe(false);
+ });
+
+ test("isFloatDtype", () => {
+ expect(api.types.isFloatDtype(Dtype.float64)).toBe(true);
+ expect(api.types.isFloatDtype(Dtype.int32)).toBe(false);
+ });
+
+ test("isBoolDtype", () => {
+ expect(api.types.isBoolDtype(Dtype.bool)).toBe(true);
+ expect(api.types.isBoolDtype(Dtype.int32)).toBe(false);
+ });
+
+ test("isStringDtype", () => {
+ expect(api.types.isStringDtype(Dtype.string)).toBe(true);
+ expect(api.types.isStringDtype(Dtype.int32)).toBe(false);
+ });
+
+ test("isCategoricalDtype", () => {
+ expect(api.types.isCategoricalDtype(Dtype.category)).toBe(true);
+ expect(api.types.isCategoricalDtype(Dtype.int32)).toBe(false);
+ });
+ });
+
+ describe("apiTypes alias", () => {
+ test("apiTypes.isScalar works the same as api.types.isScalar", () => {
+ expect(apiTypes.isScalar(1)).toBe(api.types.isScalar(1));
+ expect(apiTypes.isNumericDtype(Dtype.float64)).toBe(api.types.isNumericDtype(Dtype.float64));
+ });
+ });
+});
diff --git a/tests/core/series.map.test.ts b/tests/core/series.map.test.ts
new file mode 100644
index 00000000..5447477f
--- /dev/null
+++ b/tests/core/series.map.test.ts
@@ -0,0 +1,179 @@
+/**
+ * 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..71f45f11
--- /dev/null
+++ b/tests/groupby/grouper.test.ts
@@ -0,0 +1,110 @@
+/**
+ * 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/expanding.test.ts b/tests/window/expanding.test.ts
index cd7b3b4e..e0880b00 100644
--- a/tests/window/expanding.test.ts
+++ b/tests/window/expanding.test.ts
@@ -6,7 +6,7 @@
*/
import { describe, expect, test } from "bun:test";
-import * as fc from "fast-check";
+import { assert, array, float, integer, option, property } from "fast-check";
import { DataFrame, Series } from "../../src/index.ts";
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -290,9 +290,9 @@ describe("DataFrame.expanding()", () => {
describe("Expanding — property tests", () => {
test("expanding().sum() at position i equals sum of all valid values up to i", () => {
- fc.assert(
- fc.property(
- fc.array(fc.option(fc.float({ noNaN: true, min: -100, max: 100 }), { nil: null }), {
+ assert(
+ property(
+ array(option(float({ noNaN: true, min: -100, max: 100 }), { nil: null }), {
minLength: 0,
maxLength: 30,
}),
@@ -317,9 +317,9 @@ describe("Expanding — property tests", () => {
});
test("expanding().max() at position i >= expanding().min() at position i", () => {
- fc.assert(
- fc.property(
- fc.array(fc.float({ noNaN: true, min: -1000, max: 1000 }), {
+ assert(
+ property(
+ array(float({ noNaN: true, min: -1000, max: 1000 }), {
minLength: 1,
maxLength: 50,
}),
@@ -341,23 +341,20 @@ describe("Expanding — property tests", () => {
});
test("expanding().count() is non-decreasing", () => {
- fc.assert(
- fc.property(
- fc.array(fc.option(fc.integer(), { nil: null }), { minLength: 0, maxLength: 30 }),
- (data) => {
- const result = new Series({ data }).expanding().count().toArray();
- for (let i = 1; i < result.length; i++) {
- expect(result[i] as number).toBeGreaterThanOrEqual(result[i - 1] as number);
- }
- },
- ),
+ assert(
+ property(array(option(integer(), { nil: null }), { minLength: 0, maxLength: 30 }), (data) => {
+ const result = new Series({ data }).expanding().count().toArray();
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i] as number).toBeGreaterThanOrEqual(result[i - 1] as number);
+ }
+ }),
);
});
test("expanding().mean() matches manual computation", () => {
- fc.assert(
- fc.property(
- fc.array(fc.float({ noNaN: true, min: -100, max: 100 }), {
+ assert(
+ property(
+ array(float({ noNaN: true, min: -100, max: 100 }), {
minLength: 1,
maxLength: 20,
}),
diff --git a/tests/window/indexers.test.ts b/tests/window/indexers.test.ts
new file mode 100644
index 00000000..09bb9c28
--- /dev/null
+++ b/tests/window/indexers.test.ts
@@ -0,0 +1,233 @@
+/**
+ * Tests for window indexers (BaseIndexer, FixedForwardWindowIndexer,
+ * VariableOffsetWindowIndexer, applyIndexer).
+ *
+ * Mirrors pandas.api.indexers test suite.
+ */
+
+import { describe, expect, test } from "bun:test";
+import {
+ BaseIndexer,
+ FixedForwardWindowIndexer,
+ VariableOffsetWindowIndexer,
+ applyIndexer,
+} from "../../src/window/index.ts";
+import type { WindowBounds } from "../../src/window/index.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[]): number => nums.reduce((a, b) => a + b, 0);
+ const mean = (nums: readonly number[]): 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, [Number.NaN, Number.NaN, Number.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]);
+ });
+});
diff --git a/tests/window/rolling_apply.test.ts b/tests/window/rolling_apply.test.ts
index 5e97b4ba..a81ea887 100644
--- a/tests/window/rolling_apply.test.ts
+++ b/tests/window/rolling_apply.test.ts
@@ -114,7 +114,7 @@ describe("rollingApply", () => {
});
test("product function over window", () => {
- const prod = (nums: readonly number[]) => nums.reduce((a, b) => a * b, 1);
+ const prod = (nums: readonly number[]): number => nums.reduce((a, b) => a * b, 1);
const out = rollingApply(s(2, 3, 4, 5), 3, prod);
expect(out.toArray()).toEqual([null, null, 24, 60]);
});
| |