Detect and fill missing values. isna(), notna(), isnull(), notnull() for scalars/Series/DataFrame. ffillSeries(), bfillSeries(), dataFrameFfill(), dataFrameBfill() with optional limit and axis support.
Conditional value selection. where keeps values where the condition is true; mask replaces them. Supports boolean arrays, Series, DataFrame, and callable conditions.
+ isna / notna — detect missing values in scalars,
+ Series, and DataFrames.
+ ffill / bfill — propagate the last (or next) valid
+ value to fill gaps.
+ Mirrors pd.isna(), Series.ffill(), and
+ DataFrame.bfill() from pandas.
+
+
+
+
+
1 · isna / notna on scalars
+
+ Returns true / false for individual values.
+ null, undefined, and NaN are all
+ considered "missing".
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
2 · isna on a Series
+
+ When passed a Series, isna returns a boolean Series of the
+ same length — true where values are missing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
3 · isna on a DataFrame
+
+ Returns a DataFrame of booleans with the same shape — one column per
+ original column, true where missing.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
4 · Forward-fill (ffillSeries)
+
+ Propagates the last valid value forward to fill gaps. Leading
+ nulls that have no preceding value remain null.
+ Use the optional limit to cap consecutive fills.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
5 · Backward-fill (bfillSeries)
+
+ Propagates the next valid value backward to fill gaps. Trailing
+ nulls that have no following value remain null.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
6 · DataFrame forward-fill & backward-fill
+
+ dataFrameFfill and dataFrameBfill apply fill
+ column-wise by default (axis=0). Pass axis: 1 to fill
+ row-wise across columns.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
API Reference
+
// Module-level missing-value detection
+isna(value: Scalar): boolean
+isna(value: Series): Series<boolean>
+isna(value: DataFrame): DataFrame
+
+notna(value: Scalar): boolean
+notna(value: Series): Series<boolean>
+notna(value: DataFrame): DataFrame
+
+// Aliases
+isnull(...) // same as isna
+notnull(...) // same as notna
+
+// Series forward / backward fill
+ffillSeries(series, options?: { limit?: number | null }): Series
+bfillSeries(series, options?: { limit?: number | null }): Series
+
+// DataFrame forward / backward fill
+dataFrameFfill(df, options?: {
+ limit?: number | null, // max consecutive fills (default: no limit)
+ axis?: 0 | 1 | "index" | "columns", // default 0 (column-wise)
+}): DataFrame
+
+dataFrameBfill(df, options?: {
+ limit?: number | null,
+ axis?: 0 | 1 | "index" | "columns",
+}): DataFrame
+
+
+
+
+
+
diff --git a/src/index.ts b/src/index.ts
index 1dd0aa57..58b0b0cf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -107,3 +107,38 @@ export {
export type { ClipOptions, RoundOptions, DataFrameElemOptions } from "./stats/index.ts";
export { valueCounts, dataFrameValueCounts } from "./stats/index.ts";
export type { ValueCountsOptions, DataFrameValueCountsOptions } from "./stats/index.ts";
+export {
+ isna,
+ notna,
+ isnull,
+ notnull,
+ ffillSeries,
+ bfillSeries,
+ dataFrameFfill,
+ dataFrameBfill,
+} from "./stats/index.ts";
+export type { FillDirectionOptions, DataFrameFillOptions } from "./stats/index.ts";
+export { pctChangeSeries, pctChangeDataFrame } from "./stats/index.ts";
+export type {
+ PctChangeFillMethod,
+ PctChangeOptions,
+ DataFramePctChangeOptions,
+} from "./stats/index.ts";
+export { idxminSeries, idxmaxSeries, idxminDataFrame, idxmaxDataFrame } from "./stats/index.ts";
+export type { IdxOptions, IdxDataFrameOptions } from "./stats/index.ts";
+export { astypeSeries, astype, castScalar } from "./core/index.ts";
+export type { AstypeOptions, DataFrameAstypeOptions } from "./core/index.ts";
+export { replaceSeries, replaceDataFrame } from "./stats/index.ts";
+export type {
+ ReplaceMapping,
+ ReplaceSpec,
+ ReplaceOptions,
+ DataFrameReplaceOptions,
+} from "./stats/index.ts";
+export { whereSeries, maskSeries, whereDataFrame, maskDataFrame } from "./stats/index.ts";
+export type {
+ SeriesCond,
+ DataFrameCond,
+ WhereOptions,
+ WhereDataFrameOptions,
+} from "./stats/index.ts";
diff --git a/src/stats/index.ts b/src/stats/index.ts
index b1de48eb..755d485b 100644
--- a/src/stats/index.ts
+++ b/src/stats/index.ts
@@ -39,3 +39,36 @@ export {
nsmallestDataFrame,
} from "./nlargest.ts";
export type { NKeep, NTopOptions, NTopDataFrameOptions } from "./nlargest.ts";
+export {
+ isna,
+ notna,
+ isnull,
+ notnull,
+ ffillSeries,
+ bfillSeries,
+ dataFrameFfill,
+ dataFrameBfill,
+} from "./na_ops.ts";
+export type { FillDirectionOptions, DataFrameFillOptions } from "./na_ops.ts";
+export { pctChangeSeries, pctChangeDataFrame } from "./pct_change.ts";
+export type {
+ PctChangeFillMethod,
+ PctChangeOptions,
+ DataFramePctChangeOptions,
+} from "./pct_change.ts";
+export { idxminSeries, idxmaxSeries, idxminDataFrame, idxmaxDataFrame } from "./idxmin_idxmax.ts";
+export type { IdxOptions, IdxDataFrameOptions } from "./idxmin_idxmax.ts";
+export { replaceSeries, replaceDataFrame } from "./replace.ts";
+export type {
+ ReplaceMapping,
+ ReplaceSpec,
+ ReplaceOptions,
+ DataFrameReplaceOptions,
+} from "./replace.ts";
+export { whereSeries, maskSeries, whereDataFrame, maskDataFrame } from "./where_mask.ts";
+export type {
+ SeriesCond,
+ DataFrameCond,
+ WhereOptions,
+ WhereDataFrameOptions,
+} from "./where_mask.ts";
diff --git a/src/stats/na_ops.ts b/src/stats/na_ops.ts
new file mode 100644
index 00000000..c776bb1f
--- /dev/null
+++ b/src/stats/na_ops.ts
@@ -0,0 +1,336 @@
+/**
+ * na_ops — missing-value utilities for Series and DataFrame.
+ *
+ * Mirrors the following pandas module-level functions and methods:
+ * - `pd.isna(obj)` / `pd.isnull(obj)` — detect missing values
+ * - `pd.notna(obj)` / `pd.notnull(obj)` — detect non-missing values
+ * - `Series.ffill()` / `DataFrame.ffill()` — forward-fill missing values
+ * - `Series.bfill()` / `DataFrame.bfill()` — backward-fill missing values
+ *
+ * All functions are **pure** (return new objects; inputs are unchanged).
+ *
+ * @module
+ */
+
+import { DataFrame } from "../core/index.ts";
+import { Series } from "../core/index.ts";
+import type { Scalar } from "../types.ts";
+
+// ─── public types ─────────────────────────────────────────────────────────────
+
+/** Options for {@link ffillSeries} and {@link bfillSeries}. */
+export interface FillDirectionOptions {
+ /**
+ * Maximum number of consecutive NaN/null values to fill.
+ * `null` means no limit (default).
+ */
+ readonly limit?: number | null;
+}
+
+/** Options for {@link dataFrameFfill} and {@link dataFrameBfill}. */
+export interface DataFrameFillOptions extends FillDirectionOptions {
+ /**
+ * - `0` or `"index"` (default): fill missing values down each **column**.
+ * - `1` or `"columns"`: fill missing values across each **row**.
+ */
+ readonly axis?: 0 | 1 | "index" | "columns";
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/** True when `v` should be treated as missing. */
+function isMissing(v: Scalar): boolean {
+ return v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
+}
+
+/** Forward-fill an array of scalars in-place (returns a new array). */
+function ffillArray(vals: readonly Scalar[], limit: number | null): Scalar[] {
+ const out: Scalar[] = Array.from(vals);
+ let lastValid: Scalar = null;
+ let streak = 0;
+ for (let i = 0; i < out.length; i++) {
+ if (isMissing(out[i])) {
+ if (!isMissing(lastValid) && (limit === null || streak < limit)) {
+ out[i] = lastValid;
+ streak++;
+ }
+ } else {
+ lastValid = out[i] as Scalar;
+ streak = 0;
+ }
+ }
+ return out;
+}
+
+/** Backward-fill an array of scalars (returns a new array). */
+function bfillArray(vals: readonly Scalar[], limit: number | null): Scalar[] {
+ const out: Scalar[] = Array.from(vals);
+ let nextValid: Scalar = null;
+ let streak = 0;
+ for (let i = out.length - 1; i >= 0; i--) {
+ if (isMissing(out[i])) {
+ if (!isMissing(nextValid) && (limit === null || streak < limit)) {
+ out[i] = nextValid;
+ streak++;
+ }
+ } else {
+ nextValid = out[i] as Scalar;
+ streak = 0;
+ }
+ }
+ return out;
+}
+
+// ─── isna / notna ─────────────────────────────────────────────────────────────
+
+/**
+ * Detect missing values in a scalar, Series, or DataFrame.
+ *
+ * - For a **scalar**: returns `true` if the value is `null`, `undefined`, or `NaN`.
+ * - For a **Series**: returns a `Series` of the same index.
+ * - For a **DataFrame**: returns a `DataFrame` of boolean columns.
+ *
+ * Mirrors `pandas.isna()` / `pandas.isnull()`.
+ *
+ * @example
+ * ```ts
+ * import { isna } from "tsb";
+ * isna(null); // true
+ * isna(42); // false
+ * isna(NaN); // true
+ *
+ * const s = new Series({ data: [1, null, NaN, 4] });
+ * isna(s); // Series([false, true, true, false])
+ * ```
+ */
+export function isna(value: Scalar): boolean;
+export function isna(value: Series): Series;
+export function isna(value: DataFrame): DataFrame;
+export function isna(
+ value: Scalar | Series | DataFrame,
+): boolean | Series | DataFrame {
+ if (value instanceof DataFrame) {
+ return value.isna();
+ }
+ if (value instanceof Series) {
+ return value.isna();
+ }
+ return isMissing(value as Scalar);
+}
+
+/**
+ * Detect non-missing values in a scalar, Series, or DataFrame.
+ *
+ * Mirrors `pandas.notna()` / `pandas.notnull()`.
+ *
+ * @example
+ * ```ts
+ * import { notna } from "tsb";
+ * notna(null); // false
+ * notna(42); // true
+ * ```
+ */
+export function notna(value: Scalar): boolean;
+export function notna(value: Series): Series;
+export function notna(value: DataFrame): DataFrame;
+export function notna(
+ value: Scalar | Series | DataFrame,
+): boolean | Series | DataFrame {
+ if (value instanceof DataFrame) {
+ return value.notna();
+ }
+ if (value instanceof Series) {
+ return value.notna();
+ }
+ return !isMissing(value as Scalar);
+}
+
+/** Alias for {@link isna}. Mirrors `pandas.isnull()`. */
+export const isnull = isna;
+
+/** Alias for {@link notna}. Mirrors `pandas.notnull()`. */
+export const notnull = notna;
+
+// ─── ffill ────────────────────────────────────────────────────────────────────
+
+/**
+ * Forward-fill missing values in a Series.
+ *
+ * Each `null`/`NaN` value is replaced with the last non-missing value
+ * that precedes it (if any). Values before the first non-missing value
+ * remain missing.
+ *
+ * Mirrors `pandas.Series.ffill()`.
+ *
+ * @param series - Input Series (unchanged).
+ * @param options - Optional `{ limit }` — max consecutive fills.
+ * @returns New Series with forward-filled values.
+ *
+ * @example
+ * ```ts
+ * import { ffillSeries } from "tsb";
+ * const s = new Series({ data: [1, null, null, 4] });
+ * ffillSeries(s); // Series([1, 1, 1, 4])
+ * ```
+ */
+export function ffillSeries(
+ series: Series,
+ options?: FillDirectionOptions,
+): Series {
+ const limit = options?.limit ?? null;
+ const filled = ffillArray(series.values as readonly Scalar[], limit) as T[];
+ return new Series({
+ data: filled,
+ index: series.index,
+ dtype: series.dtype,
+ name: series.name ?? undefined,
+ });
+}
+
+/**
+ * Backward-fill missing values in a Series.
+ *
+ * Each `null`/`NaN` value is replaced with the next non-missing value
+ * that follows it (if any). Values after the last non-missing value
+ * remain missing.
+ *
+ * Mirrors `pandas.Series.bfill()`.
+ *
+ * @example
+ * ```ts
+ * import { bfillSeries } from "tsb";
+ * const s = new Series({ data: [1, null, null, 4] });
+ * bfillSeries(s); // Series([1, 4, 4, 4])
+ * ```
+ */
+export function bfillSeries(
+ series: Series,
+ options?: FillDirectionOptions,
+): Series {
+ const limit = options?.limit ?? null;
+ const filled = bfillArray(series.values as readonly Scalar[], limit) as T[];
+ return new Series({
+ data: filled,
+ index: series.index,
+ dtype: series.dtype,
+ name: series.name ?? undefined,
+ });
+}
+
+// ─── DataFrame ffill / bfill ──────────────────────────────────────────────────
+
+/**
+ * Forward-fill missing values in a DataFrame.
+ *
+ * By default operates **column-wise** (axis=0): each column is independently
+ * forward-filled. With `axis=1` each row is forward-filled across columns.
+ *
+ * Mirrors `pandas.DataFrame.ffill()`.
+ *
+ * @example
+ * ```ts
+ * import { dataFrameFfill } from "tsb";
+ * const df = new DataFrame({ data: { a: [1, null, 3], b: [null, 2, null] } });
+ * dataFrameFfill(df);
+ * // a: [1, 1, 3]
+ * // b: [null, 2, 2]
+ * ```
+ */
+export function dataFrameFfill(df: DataFrame, options?: DataFrameFillOptions): DataFrame {
+ const limit = options?.limit ?? null;
+ const axis = options?.axis ?? 0;
+ const byRow = axis === 1 || axis === "columns";
+
+ if (!byRow) {
+ // column-wise: fill each column independently
+ const colMap = new Map>();
+ for (const name of df.columns.values) {
+ const col = df.col(name);
+ const filled = ffillArray(col.values, limit) as Scalar[];
+ colMap.set(name, new Series({ data: filled, index: col.index, dtype: col.dtype }));
+ }
+ return new DataFrame(colMap, df.index);
+ }
+
+ // row-wise: fill across columns for each row
+ const nRows = df.shape[0];
+ const cols = df.columns.values;
+ const columns = cols.map((name) => df.col(name));
+ const rowsFilled: Scalar[][] = columns.map((c) => Array.from(c.values));
+ for (let r = 0; r < nRows; r++) {
+ const rowVals: Scalar[] = columns.map((_, ci) => rowsFilled[ci]?.[r] ?? null);
+ const filled = ffillArray(rowVals, limit);
+ for (let ci = 0; ci < cols.length; ci++) {
+ const rowsFilledCI = rowsFilled[ci];
+ if (rowsFilledCI !== undefined) {
+ rowsFilledCI[r] = filled[ci] ?? null;
+ }
+ }
+ }
+ const colMap = new Map>();
+ for (let ci = 0; ci < cols.length; ci++) {
+ const name = cols[ci] as string;
+ const col = columns[ci] as Series;
+ colMap.set(
+ name,
+ new Series({
+ data: rowsFilled[ci] ?? [],
+ index: col.index,
+ dtype: col.dtype,
+ }),
+ );
+ }
+ return new DataFrame(colMap, df.index);
+}
+
+/**
+ * Backward-fill missing values in a DataFrame.
+ *
+ * By default operates **column-wise** (axis=0). With `axis=1` fills across rows.
+ *
+ * Mirrors `pandas.DataFrame.bfill()`.
+ */
+export function dataFrameBfill(df: DataFrame, options?: DataFrameFillOptions): DataFrame {
+ const limit = options?.limit ?? null;
+ const axis = options?.axis ?? 0;
+ const byRow = axis === 1 || axis === "columns";
+
+ if (!byRow) {
+ const colMap = new Map>();
+ for (const name of df.columns.values) {
+ const col = df.col(name);
+ const filled = bfillArray(col.values, limit) as Scalar[];
+ colMap.set(name, new Series({ data: filled, index: col.index, dtype: col.dtype }));
+ }
+ return new DataFrame(colMap, df.index);
+ }
+
+ const nRows = df.shape[0];
+ const cols = df.columns.values;
+ const columns = cols.map((name) => df.col(name));
+ const rowsFilled: Scalar[][] = columns.map((c) => Array.from(c.values));
+ for (let r = 0; r < nRows; r++) {
+ const rowVals: Scalar[] = columns.map((_, ci) => rowsFilled[ci]?.[r] ?? null);
+ const filled = bfillArray(rowVals, limit);
+ for (let ci = 0; ci < cols.length; ci++) {
+ const rowsFilledCI = rowsFilled[ci];
+ if (rowsFilledCI !== undefined) {
+ rowsFilledCI[r] = filled[ci] ?? null;
+ }
+ }
+ }
+ const colMap = new Map>();
+ for (let ci = 0; ci < cols.length; ci++) {
+ const name = cols[ci] as string;
+ const col = columns[ci] as Series;
+ colMap.set(
+ name,
+ new Series({
+ data: rowsFilled[ci] ?? [],
+ index: col.index,
+ dtype: col.dtype,
+ }),
+ );
+ }
+ return new DataFrame(colMap, df.index);
+}
diff --git a/tests/stats/na_ops.test.ts b/tests/stats/na_ops.test.ts
new file mode 100644
index 00000000..340406ac
--- /dev/null
+++ b/tests/stats/na_ops.test.ts
@@ -0,0 +1,280 @@
+/**
+ * Tests for na_ops — missing-value utilities (isna, notna, ffill, bfill).
+ */
+
+import { describe, expect, it } from "bun:test";
+import fc from "fast-check";
+import {
+ DataFrame,
+ Series,
+ bfillSeries,
+ dataFrameBfill,
+ dataFrameFfill,
+ ffillSeries,
+ isna,
+ isnull,
+ notna,
+ notnull,
+} from "../../src/index.ts";
+
+// ─── isna / notna ─────────────────────────────────────────────────────────────
+
+describe("isna (scalar)", () => {
+ it("returns true for null", () => expect(isna(null)).toBe(true));
+ it("returns true for undefined", () => expect(isna(undefined)).toBe(true));
+ it("returns true for NaN", () => expect(isna(Number.NaN)).toBe(true));
+ it("returns false for 0", () => expect(isna(0)).toBe(false));
+ it("returns false for empty string", () => expect(isna("")).toBe(false));
+ it("returns false for false", () => expect(isna(false)).toBe(false));
+ it("returns false for a number", () => expect(isna(42)).toBe(false));
+});
+
+describe("notna (scalar)", () => {
+ it("returns false for null", () => expect(notna(null)).toBe(false));
+ it("returns false for NaN", () => expect(notna(Number.NaN)).toBe(false));
+ it("returns true for 42", () => expect(notna(42)).toBe(true));
+ it("returns true for a string", () => expect(notna("hello")).toBe(true));
+});
+
+describe("isnull / notnull aliases", () => {
+ it("isnull equals isna for scalar", () => {
+ expect(isnull(null)).toBe(isna(null));
+ expect(isnull(42)).toBe(isna(42));
+ });
+ it("notnull equals notna for scalar", () => {
+ expect(notnull(null)).toBe(notna(null));
+ expect(notnull(42)).toBe(notna(42));
+ });
+});
+
+describe("isna (Series)", () => {
+ it("returns boolean Series of correct length", () => {
+ const s = new Series({ data: [1, null, Number.NaN, 4] });
+ const result = isna(s);
+ expect(result).toBeInstanceOf(Series);
+ expect([...result.values]).toEqual([false, true, true, false]);
+ });
+
+ it("all present", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ expect([...isna(s).values]).toEqual([false, false, false]);
+ });
+
+ it("all missing", () => {
+ const s = new Series({ data: [null, null, Number.NaN] });
+ expect([...isna(s).values]).toEqual([true, true, true]);
+ });
+});
+
+describe("notna (Series)", () => {
+ it("is the inverse of isna", () => {
+ const s = new Series({ data: [1, null, Number.NaN, 4] });
+ const na = isna(s).values;
+ const nna = notna(s).values;
+ for (let i = 0; i < na.length; i++) {
+ expect(nna[i]).toBe(!na[i]);
+ }
+ });
+});
+
+describe("isna (DataFrame)", () => {
+ it("returns DataFrame of booleans", () => {
+ const df = DataFrame.fromColumns({ a: [1, null], b: [Number.NaN, 2] });
+ const result = isna(df);
+ expect(result).toBeInstanceOf(DataFrame);
+ expect([...result.col("a").values]).toEqual([false, true]);
+ expect([...result.col("b").values]).toEqual([true, false]);
+ });
+});
+
+describe("notna (DataFrame)", () => {
+ it("returns inverse of isna DataFrame", () => {
+ const df = DataFrame.fromColumns({ a: [1, null], b: [Number.NaN, 2] });
+ expect([...notna(df).col("a").values]).toEqual([true, false]);
+ expect([...notna(df).col("b").values]).toEqual([false, true]);
+ });
+});
+
+// ─── ffillSeries ──────────────────────────────────────────────────────────────
+
+describe("ffillSeries", () => {
+ it("fills nulls with preceding value", () => {
+ const s = new Series({ data: [1, null, null, 4] });
+ expect([...ffillSeries(s).values]).toEqual([1, 1, 1, 4]);
+ });
+
+ it("leaves leading nulls untouched", () => {
+ const s = new Series({ data: [null, null, 3, null] });
+ expect([...ffillSeries(s).values]).toEqual([null, null, 3, 3]);
+ });
+
+ it("NaN is treated as missing", () => {
+ const s = new Series({ data: [2, Number.NaN, 5] });
+ const result = ffillSeries(s).values;
+ expect(result[0]).toBe(2);
+ expect(result[1]).toBe(2);
+ expect(result[2]).toBe(5);
+ });
+
+ it("respects limit option", () => {
+ const s = new Series({ data: [1, null, null, null, 5] });
+ expect([...ffillSeries(s, { limit: 1 }).values]).toEqual([1, 1, null, null, 5]);
+ });
+
+ it("preserves original Series", () => {
+ const s = new Series({ data: [1, null, 3] });
+ ffillSeries(s);
+ expect([...s.values]).toEqual([1, null, 3]);
+ });
+
+ it("empty Series returns empty", () => {
+ const s = new Series({ data: [] });
+ expect([...ffillSeries(s).values]).toEqual([]);
+ });
+
+ it("preserves name and index", () => {
+ const s = new Series({ data: [1, null], name: "x" });
+ const filled = ffillSeries(s);
+ expect(filled.name).toBe("x");
+ expect(filled.index.size).toBe(2);
+ });
+});
+
+// ─── bfillSeries ──────────────────────────────────────────────────────────────
+
+describe("bfillSeries", () => {
+ it("fills nulls with following value", () => {
+ const s = new Series({ data: [1, null, null, 4] });
+ expect([...bfillSeries(s).values]).toEqual([1, 4, 4, 4]);
+ });
+
+ it("leaves trailing nulls untouched", () => {
+ const s = new Series({ data: [null, 3, null, null] });
+ expect([...bfillSeries(s).values]).toEqual([3, 3, null, null]);
+ });
+
+ it("respects limit option", () => {
+ const s = new Series({ data: [1, null, null, null, 5] });
+ expect([...bfillSeries(s, { limit: 2 }).values]).toEqual([1, null, 5, 5, 5]);
+ });
+
+ it("empty Series returns empty", () => {
+ const s = new Series({ data: [] });
+ expect([...bfillSeries(s).values]).toEqual([]);
+ });
+});
+
+// ─── dataFrameFfill ───────────────────────────────────────────────────────────
+
+describe("dataFrameFfill (column-wise)", () => {
+ it("fills each column independently", () => {
+ const df = DataFrame.fromColumns({ a: [1, null, 3], b: [null, 2, null] });
+ const result = dataFrameFfill(df);
+ expect([...result.col("a").values]).toEqual([1, 1, 3]);
+ expect([...result.col("b").values]).toEqual([null, 2, 2]);
+ });
+
+ it("preserves index", () => {
+ const df = DataFrame.fromColumns({ x: [1, null] });
+ expect(dataFrameFfill(df).index.size).toBe(2);
+ });
+});
+
+describe("dataFrameFfill (row-wise)", () => {
+ it("fills across columns per row", () => {
+ const df = DataFrame.fromColumns({ a: [1, null], b: [null, null], c: [3, 4] });
+ const result = dataFrameFfill(df, { axis: 1 });
+ expect([...result.col("a").values]).toEqual([1, null]);
+ expect([...result.col("b").values]).toEqual([1, null]);
+ expect([...result.col("c").values]).toEqual([3, 4]);
+ });
+});
+
+// ─── dataFrameBfill ───────────────────────────────────────────────────────────
+
+describe("dataFrameBfill (column-wise)", () => {
+ it("fills each column backward", () => {
+ const df = DataFrame.fromColumns({ a: [null, null, 3], b: [1, null, null] });
+ const result = dataFrameBfill(df);
+ expect([...result.col("a").values]).toEqual([3, 3, 3]);
+ expect([...result.col("b").values]).toEqual([1, null, null]);
+ });
+});
+
+describe("dataFrameBfill (row-wise)", () => {
+ it("fills backward across columns per row", () => {
+ const df = DataFrame.fromColumns({ a: [null, 1], b: [null, null], c: [3, null] });
+ const result = dataFrameBfill(df, { axis: 1 });
+ expect([...result.col("a").values]).toEqual([3, 1]);
+ expect([...result.col("b").values]).toEqual([3, null]);
+ expect([...result.col("c").values]).toEqual([3, null]);
+ });
+});
+
+// ─── property-based tests ─────────────────────────────────────────────────────
+
+describe("property: ffill followed by bfill fills all if any non-null", () => {
+ it("all values filled when at least one is present", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.option(fc.integer({ min: 0, max: 100 }), { nil: null }), {
+ minLength: 1,
+ maxLength: 20,
+ }),
+ (raw) => {
+ const hasNonNull = raw.some((v) => v !== null);
+ if (!hasNonNull) {
+ return true;
+ }
+ const s = new Series({ data: raw });
+ const result = bfillSeries(ffillSeries(s));
+ return result.values.every((v) => v !== null);
+ },
+ ),
+ );
+ });
+});
+
+describe("property: ffill never introduces new non-null values beyond last valid", () => {
+ it("ffilled series has no nulls after first valid value", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.option(fc.integer({ min: -50, max: 50 }), { nil: null }), {
+ minLength: 0,
+ maxLength: 30,
+ }),
+ (raw) => {
+ const s = new Series({ data: raw });
+ const filled = ffillSeries(s).values;
+ let sawValid = false;
+ for (const v of filled) {
+ if (v !== null) {
+ sawValid = true;
+ }
+ if (sawValid && v === null) {
+ return false;
+ }
+ }
+ return true;
+ },
+ ),
+ );
+ });
+});
+
+describe("property: isna is inverse of notna for scalars", () => {
+ it("isna(v) === !notna(v)", () => {
+ fc.assert(
+ fc.property(
+ fc.oneof(
+ fc.integer(),
+ fc.float({ noNaN: false }),
+ fc.constant(null),
+ fc.string(),
+ fc.boolean(),
+ ),
+ (v) => isna(v as Parameters[0]) === !notna(v as Parameters[0]),
+ ),
+ );
+ });
+});
From f33dde0c1dffb20e0f1a491164019ea1c4ae8dc8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 13:43:34 +0000
Subject: [PATCH 2/6] Iteration 174: Add pct_change for Series and DataFrame
Implements pctChangeSeries() and pctChangeDataFrame() mirroring
pandas.Series.pct_change() / pandas.DataFrame.pct_change().
- periods: configurable lag (positive = backward, negative = forward)
- fillMethod: "pad" (default), "bfill", or null (no fill)
- limit: cap consecutive fills
- axis: column-wise (default) or row-wise for DataFrame
Full test coverage: unit tests, edge cases, and fast-check property tests.
Interactive playground page at playground/pct_change.html.
Run: https://github.com/githubnext/tsessebe/actions/runs/24266545401
---
playground/pct_change.html | 448 +++++++++++++++++++++++++++++++++
src/stats/pct_change.ts | 231 +++++++++++++++++
tests/stats/pct_change.test.ts | 252 +++++++++++++++++++
3 files changed, 931 insertions(+)
create mode 100644 playground/pct_change.html
create mode 100644 src/stats/pct_change.ts
create mode 100644 tests/stats/pct_change.test.ts
diff --git a/playground/pct_change.html b/playground/pct_change.html
new file mode 100644
index 00000000..3576797a
--- /dev/null
+++ b/playground/pct_change.html
@@ -0,0 +1,448 @@
+
+
+
+
+
+ tsb — pct_change
+
+
+
+
Compute the fractional change between each element and a prior element.
+ Mirrors pandas.Series.pct_change() /
+ pandas.DataFrame.pct_change().
+ Edit any code block below and press ▶ Run
+ (or Ctrl+Enter) to execute it live in your browser.
+
+
+
+
+
1 · Basic pct_change on a Series
+
pctChangeSeries(series) returns the fractional (not percentage) change
+ from each previous element. The first element is always null.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
2 · Multi-period change
+
The periods option controls the lag. Use periods: 2 to
+ compare each value to the one two steps earlier — useful for month-over-month
+ comparisons in quarterly data.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
3 · Handling missing values
+
By default, pctChangeSeries forward-fills (fillMethod: "pad")
+ NaN/null values before computing the ratio — so gaps don't break the chain.
+ Set fillMethod: null to propagate NaN instead.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
4 · Limit consecutive fills
+
The limit option caps how many consecutive NaN values get forward-filled.
+ Useful when you want to tolerate short gaps but not bridge large ones.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
5 · DataFrame column-wise pct_change
+
pctChangeDataFrame(df) applies pctChangeSeries to every
+ column independently. Ideal for comparing multiple assets or metrics simultaneously.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
6 · Negative periods (look-forward change)
+
A negative periods value computes the forward change: how much will
+ this element change by the time we reach |periods| steps ahead.
+ Useful for computing returns on a "hold for N periods" strategy.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
API Reference
+
All functions return a new Series/DataFrame of the same shape — inputs are never mutated.
+ Return the index label of the minimum or maximum value in a
+ Series or each column of a DataFrame.
+ Mirrors pandas.Series.idxmin(), idxmax(),
+ pandas.DataFrame.idxmin(), and DataFrame.idxmax().
+
+
+
+
+
1 · Series.idxmin — label of the minimum value
+
Returns the index label at the position of the minimum value.
+ NaN / null values are skipped by default.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
2 · Series.idxmax — label of the maximum value
+
Returns the index label at the position of the maximum value.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
3 · NaN handling — skipna option
+
By default NaN / null values are skipped. Set skipna: false
+ to propagate NaN (returns null if any value is NaN).
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
4 · DataFrame.idxmin — row label of column minima
+
Returns a Series indexed by column names. Each value is the row label
+ where that column achieves its minimum.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
5 · DataFrame.idxmax — row label of column maxima
+
Returns a Series indexed by column names, where each entry is the row
+ label of that column's maximum value.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
6 · Edge cases — empty, all-NaN, all-equal
+
Behavior for empty series, series where every value is NaN, and series
+ where all values are equal.
+
+
+ TypeScript
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
API Reference
+
// Series
+idxminSeries(series, { skipna?: boolean }): Label // default skipna=true
+idxmaxSeries(series, { skipna?: boolean }): Label
+
+// DataFrame (axis=0 — min/max per column)
+idxminDataFrame(df, { skipna?: boolean }): Series // indexed by column names
+idxmaxDataFrame(df, { skipna?: boolean }): Series
+
+
+
+
+
+
diff --git a/src/stats/idxmin_idxmax.ts b/src/stats/idxmin_idxmax.ts
new file mode 100644
index 00000000..6ee745f9
--- /dev/null
+++ b/src/stats/idxmin_idxmax.ts
@@ -0,0 +1,234 @@
+/**
+ * idxmin / idxmax — return the index label of the minimum or maximum value.
+ *
+ * Mirrors `pandas.Series.idxmin()` / `pandas.Series.idxmax()` and
+ * `pandas.DataFrame.idxmin()` / `pandas.DataFrame.idxmax()`:
+ *
+ * - `idxminSeries(series)` — label of the minimum value (NaN/null excluded)
+ * - `idxmaxSeries(series)` — label of the maximum value (NaN/null excluded)
+ * - `idxminDataFrame(df)` — Series of row labels where each column achieves its min
+ * - `idxmaxDataFrame(df)` — Series of row labels where each column achieves its max
+ *
+ * When `skipna` is true (the default), NaN / null values are ignored.
+ * When `skipna` is false, any NaN / null causes the result to be `null`.
+ *
+ * @module
+ */
+
+import type { DataFrame } from "../core/index.ts";
+import { Dtype, Series } from "../core/index.ts";
+import type { Label, Scalar } from "../types.ts";
+
+// ─── public types ─────────────────────────────────────────────────────────────
+
+/** Options for {@link idxminSeries}, {@link idxmaxSeries}. */
+export interface IdxOptions {
+ /**
+ * Whether to skip NaN / null values.
+ * @defaultValue `true`
+ */
+ readonly skipna?: boolean;
+}
+
+/** Options for {@link idxminDataFrame}, {@link idxmaxDataFrame}. */
+export interface IdxDataFrameOptions {
+ /**
+ * Whether to skip NaN / null values.
+ * @defaultValue `true`
+ */
+ readonly skipna?: boolean;
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/** True when a scalar should be treated as missing. */
+function isMissing(v: Scalar): boolean {
+ return v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
+}
+
+/**
+ * Find the index of the extreme value (min or max) among `values`.
+ * Returns `null` when all values are missing (with `skipna=true`) or when
+ * any value is missing (with `skipna=false`).
+ */
+function findExtreme(
+ values: readonly Scalar[],
+ skipna: boolean,
+ isBetter: (a: Scalar, b: Scalar) => boolean,
+): number | null {
+ let bestIdx: number | null = null;
+ let bestVal: Scalar = null;
+
+ for (let i = 0; i < values.length; i++) {
+ const v = values[i] as Scalar;
+ if (isMissing(v)) {
+ if (!skipna) {
+ return null;
+ }
+ continue;
+ }
+ if (bestIdx === null || isBetter(v, bestVal)) {
+ bestIdx = i;
+ bestVal = v;
+ }
+ }
+ return bestIdx;
+}
+
+/** Compare scalars: returns true if `a` is less than `b`. */
+function isLess(a: Scalar, b: Scalar): boolean {
+ if (b === null || b === undefined) {
+ return false;
+ }
+ return (a as number | string | boolean) < (b as number | string | boolean);
+}
+
+/** Compare scalars: returns true if `a` is greater than `b`. */
+function isGreater(a: Scalar, b: Scalar): boolean {
+ if (b === null || b === undefined) {
+ return false;
+ }
+ return (a as number | string | boolean) > (b as number | string | boolean);
+}
+
+// ─── public API — Series ──────────────────────────────────────────────────────
+
+/**
+ * Return the index label of the minimum value in `series`.
+ *
+ * NaN / null values are excluded when `skipna` is true (the default).
+ * Returns `null` when the series is empty or all values are NaN / null.
+ *
+ * Mirrors `pandas.Series.idxmin()`.
+ *
+ * @param series - Input Series.
+ * @param options - Options (skipna).
+ * @returns The index label at the minimum value, or `null` if no valid value exists.
+ *
+ * @example
+ * ```ts
+ * import { Series, idxminSeries } from "tsb";
+ *
+ * const s = new Series({ data: [3, 1, 4, 1, 5], index: ["a", "b", "c", "d", "e"] });
+ * idxminSeries(s); // "b" (first occurrence of 1)
+ * ```
+ */
+export function idxminSeries(series: Series, options: IdxOptions = {}): Label {
+ const skipna = options.skipna ?? true;
+ const idx = findExtreme(series.values, skipna, isLess);
+ if (idx === null) {
+ return null;
+ }
+ return series.index.at(idx);
+}
+
+/**
+ * Return the index label of the maximum value in `series`.
+ *
+ * NaN / null values are excluded when `skipna` is true (the default).
+ * Returns `null` when the series is empty or all values are NaN / null.
+ *
+ * Mirrors `pandas.Series.idxmax()`.
+ *
+ * @param series - Input Series.
+ * @param options - Options (skipna).
+ * @returns The index label at the maximum value, or `null` if no valid value exists.
+ *
+ * @example
+ * ```ts
+ * import { Series, idxmaxSeries } from "tsb";
+ *
+ * const s = new Series({ data: [3, 1, 4, 1, 5], index: ["a", "b", "c", "d", "e"] });
+ * idxmaxSeries(s); // "e"
+ * ```
+ */
+export function idxmaxSeries(series: Series, options: IdxOptions = {}): Label {
+ const skipna = options.skipna ?? true;
+ const idx = findExtreme(series.values, skipna, isGreater);
+ if (idx === null) {
+ return null;
+ }
+ return series.index.at(idx);
+}
+
+// ─── public API — DataFrame ───────────────────────────────────────────────────
+
+/**
+ * Return a Series containing the index label of the minimum value for each column.
+ *
+ * The result Series is indexed by column names.
+ * NaN / null values are excluded when `skipna` is true (the default).
+ * Columns where all values are NaN / null yield `null` in the result.
+ *
+ * Mirrors `pandas.DataFrame.idxmin()` (axis=0).
+ *
+ * @param df - Input DataFrame.
+ * @param options - Options (skipna).
+ * @returns A Series indexed by column names, containing the row label of each column's min.
+ *
+ * @example
+ * ```ts
+ * import { DataFrame, idxminDataFrame } from "tsb";
+ *
+ * const df = DataFrame.fromColumns({ a: [3, 1, 4], b: [10, 20, 5] }, { index: ["x", "y", "z"] });
+ * idxminDataFrame(df).values; // ["y", "z"]
+ * ```
+ */
+export function idxminDataFrame(df: DataFrame, options: IdxDataFrameOptions = {}): Series {
+ const skipna = options.skipna ?? true;
+ const colNames = df.columns.values;
+ const result: Label[] = colNames.map((colName) => {
+ const s = df.col(colName);
+ const idx = findExtreme(s.values, skipna, isLess);
+ if (idx === null) {
+ return null;
+ }
+ return df.index.at(idx);
+ });
+ return new Series({
+ data: result,
+ index: colNames as unknown as Label[],
+ name: null,
+ dtype: Dtype.from("object"),
+ });
+}
+
+/**
+ * Return a Series containing the index label of the maximum value for each column.
+ *
+ * The result Series is indexed by column names.
+ * NaN / null values are excluded when `skipna` is true (the default).
+ * Columns where all values are NaN / null yield `null` in the result.
+ *
+ * Mirrors `pandas.DataFrame.idxmax()` (axis=0).
+ *
+ * @param df - Input DataFrame.
+ * @param options - Options (skipna).
+ * @returns A Series indexed by column names, containing the row label of each column's max.
+ *
+ * @example
+ * ```ts
+ * import { DataFrame, idxmaxDataFrame } from "tsb";
+ *
+ * const df = DataFrame.fromColumns({ a: [3, 1, 4], b: [10, 20, 5] }, { index: ["x", "y", "z"] });
+ * idxmaxDataFrame(df).values; // ["z", "y"]
+ * ```
+ */
+export function idxmaxDataFrame(df: DataFrame, options: IdxDataFrameOptions = {}): Series {
+ const skipna = options.skipna ?? true;
+ const colNames = df.columns.values;
+ const result: Label[] = colNames.map((colName) => {
+ const s = df.col(colName);
+ const idx = findExtreme(s.values, skipna, isGreater);
+ if (idx === null) {
+ return null;
+ }
+ return df.index.at(idx);
+ });
+ return new Series({
+ data: result,
+ index: colNames as unknown as Label[],
+ name: null,
+ dtype: Dtype.from("object"),
+ });
+}
diff --git a/tests/stats/idxmin_idxmax.test.ts b/tests/stats/idxmin_idxmax.test.ts
new file mode 100644
index 00000000..05cfd459
--- /dev/null
+++ b/tests/stats/idxmin_idxmax.test.ts
@@ -0,0 +1,270 @@
+/**
+ * Tests for src/stats/idxmin_idxmax.ts
+ * — idxminSeries, idxmaxSeries, idxminDataFrame, idxmaxDataFrame
+ */
+import { describe, expect, it } from "bun:test";
+import fc from "fast-check";
+import {
+ DataFrame,
+ Series,
+ idxmaxDataFrame,
+ idxmaxSeries,
+ idxminDataFrame,
+ idxminSeries,
+} from "../../src/index.ts";
+import type { Label, Scalar } from "../../src/index.ts";
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
+function s(data: readonly Scalar[], index?: readonly Label[]): Series {
+ return new Series({ data: [...data], ...(index !== undefined ? { index: [...index] } : {}) });
+}
+
+// ─── idxminSeries ─────────────────────────────────────────────────────────────
+
+describe("idxminSeries", () => {
+ it("returns label of the minimum value", () => {
+ const series = s([3, 1, 4, 1, 5], ["a", "b", "c", "d", "e"]);
+ expect(idxminSeries(series)).toBe("b"); // first occurrence of minimum 1
+ });
+
+ it("returns integer index label for default index", () => {
+ const series = s([10, 3, 7]);
+ expect(idxminSeries(series)).toBe(1);
+ });
+
+ it("handles single element", () => {
+ const series = s([42], ["x"]);
+ expect(idxminSeries(series)).toBe("x");
+ });
+
+ it("returns null for empty series", () => {
+ const series = s([]);
+ expect(idxminSeries(series)).toBeNull();
+ });
+
+ it("skips NaN by default (skipna=true)", () => {
+ const series = s([Number.NaN, 2, 1, Number.NaN], ["a", "b", "c", "d"]);
+ expect(idxminSeries(series)).toBe("c");
+ });
+
+ it("skips null values by default", () => {
+ const series = s([null, 5, 2, null], ["a", "b", "c", "d"]);
+ expect(idxminSeries(series)).toBe("c");
+ });
+
+ it("returns null when all values are NaN with skipna=true", () => {
+ const series = s([Number.NaN, Number.NaN], ["a", "b"]);
+ expect(idxminSeries(series)).toBeNull();
+ });
+
+ it("returns null when any value is NaN with skipna=false", () => {
+ const series = s([1, Number.NaN, 3], ["a", "b", "c"]);
+ expect(idxminSeries(series, { skipna: false })).toBeNull();
+ });
+
+ it("returns correct label with skipna=false when no NaN", () => {
+ const series = s([5, 2, 8], ["a", "b", "c"]);
+ expect(idxminSeries(series, { skipna: false })).toBe("b");
+ });
+
+ it("handles negative numbers", () => {
+ const series = s([-1, -5, -3], ["x", "y", "z"]);
+ expect(idxminSeries(series)).toBe("y");
+ });
+
+ it("handles all equal values — returns first label", () => {
+ const series = s([7, 7, 7], ["p", "q", "r"]);
+ expect(idxminSeries(series)).toBe("p");
+ });
+
+ it("works with string values (lexicographic min)", () => {
+ const series = s(["banana", "apple", "cherry"], ["a", "b", "c"]);
+ expect(idxminSeries(series)).toBe("b"); // "apple" < "banana" < "cherry"
+ });
+
+ it("handles NaN at the start with skipna=true", () => {
+ const series = s([Number.NaN, 3, 1], ["a", "b", "c"]);
+ expect(idxminSeries(series)).toBe("c");
+ });
+});
+
+// ─── idxmaxSeries ─────────────────────────────────────────────────────────────
+
+describe("idxmaxSeries", () => {
+ it("returns label of the maximum value", () => {
+ const series = s([3, 1, 4, 1, 5], ["a", "b", "c", "d", "e"]);
+ expect(idxmaxSeries(series)).toBe("e");
+ });
+
+ it("returns integer index label for default index", () => {
+ const series = s([10, 3, 7]);
+ expect(idxmaxSeries(series)).toBe(0);
+ });
+
+ it("handles single element", () => {
+ const series = s([42], ["x"]);
+ expect(idxmaxSeries(series)).toBe("x");
+ });
+
+ it("returns null for empty series", () => {
+ const series = s([]);
+ expect(idxmaxSeries(series)).toBeNull();
+ });
+
+ it("skips NaN by default (skipna=true)", () => {
+ const series = s([Number.NaN, 2, 9, Number.NaN], ["a", "b", "c", "d"]);
+ expect(idxmaxSeries(series)).toBe("c");
+ });
+
+ it("returns null when all values are NaN with skipna=true", () => {
+ const series = s([Number.NaN, Number.NaN], ["a", "b"]);
+ expect(idxmaxSeries(series)).toBeNull();
+ });
+
+ it("returns null when any value is NaN with skipna=false", () => {
+ const series = s([1, Number.NaN, 3], ["a", "b", "c"]);
+ expect(idxmaxSeries(series, { skipna: false })).toBeNull();
+ });
+
+ it("handles negative numbers", () => {
+ const series = s([-1, -5, -3], ["x", "y", "z"]);
+ expect(idxmaxSeries(series)).toBe("x");
+ });
+
+ it("all equal — returns first label", () => {
+ const series = s([3, 3, 3], ["p", "q", "r"]);
+ expect(idxmaxSeries(series)).toBe("p");
+ });
+
+ it("works with string values (lexicographic max)", () => {
+ const series = s(["banana", "apple", "cherry"], ["a", "b", "c"]);
+ expect(idxmaxSeries(series)).toBe("c"); // "cherry" > "banana" > "apple"
+ });
+});
+
+// ─── idxminDataFrame ──────────────────────────────────────────────────────────
+
+describe("idxminDataFrame", () => {
+ it("returns row label of minimum for each column", () => {
+ const df = DataFrame.fromColumns({ a: [3, 1, 4], b: [10, 20, 5] }, { index: ["x", "y", "z"] });
+ const result = idxminDataFrame(df);
+ expect(result.at("a")).toBe("y"); // min of a is 1 at row "y"
+ expect(result.at("b")).toBe("z"); // min of b is 5 at row "z"
+ });
+
+ it("result is indexed by column names", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2], b: [3, 4] });
+ const result = idxminDataFrame(df);
+ expect([...result.index.values]).toEqual(["a", "b"]);
+ });
+
+ it("skips NaN by default", () => {
+ const df = DataFrame.fromColumns(
+ { a: [Number.NaN, 2, 1], b: [5, Number.NaN, 3] },
+ { index: ["x", "y", "z"] },
+ );
+ const result = idxminDataFrame(df);
+ expect(result.at("a")).toBe("z");
+ expect(result.at("b")).toBe("z");
+ });
+
+ it("returns null for column with all NaN (skipna=true)", () => {
+ const df = DataFrame.fromColumns(
+ { a: [1, 2], b: [Number.NaN, Number.NaN] },
+ { index: ["x", "y"] },
+ );
+ const result = idxminDataFrame(df);
+ expect(result.at("a")).toBe("x");
+ expect(result.at("b")).toBeNull();
+ });
+
+ it("handles single row DataFrame", () => {
+ const df = DataFrame.fromColumns({ a: [42], b: [7] }, { index: ["row0"] });
+ const result = idxminDataFrame(df);
+ expect(result.at("a")).toBe("row0");
+ expect(result.at("b")).toBe("row0");
+ });
+});
+
+// ─── idxmaxDataFrame ──────────────────────────────────────────────────────────
+
+describe("idxmaxDataFrame", () => {
+ it("returns row label of maximum for each column", () => {
+ const df = DataFrame.fromColumns({ a: [3, 1, 4], b: [10, 20, 5] }, { index: ["x", "y", "z"] });
+ const result = idxmaxDataFrame(df);
+ expect(result.at("a")).toBe("z"); // max of a is 4 at row "z"
+ expect(result.at("b")).toBe("y"); // max of b is 20 at row "y"
+ });
+
+ it("result is indexed by column names", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2], b: [3, 4] });
+ const result = idxmaxDataFrame(df);
+ expect([...result.index.values]).toEqual(["a", "b"]);
+ });
+
+ it("skips NaN by default", () => {
+ const df = DataFrame.fromColumns(
+ { a: [Number.NaN, 2, 1], b: [5, Number.NaN, 3] },
+ { index: ["x", "y", "z"] },
+ );
+ const result = idxmaxDataFrame(df);
+ expect(result.at("a")).toBe("y");
+ expect(result.at("b")).toBe("x");
+ });
+
+ it("handles single row DataFrame", () => {
+ const df = DataFrame.fromColumns({ a: [42], b: [7] }, { index: ["row0"] });
+ const result = idxmaxDataFrame(df);
+ expect(result.at("a")).toBe("row0");
+ expect(result.at("b")).toBe("row0");
+ });
+});
+
+// ─── property-based tests ─────────────────────────────────────────────────────
+
+describe("idxminSeries property tests", () => {
+ it("idxmin label points to minimum value in series", () => {
+ fc.assert(
+ fc.property(fc.array(fc.double({ noNaN: true }), { minLength: 1, maxLength: 20 }), (data) => {
+ const series = s(data);
+ const label = idxminSeries(series);
+ if (label === null) {
+ return true;
+ }
+ const minVal = Math.min(...data);
+ return series.at(label as number) === minVal;
+ }),
+ );
+ });
+
+ it("idxmax label points to maximum value in series", () => {
+ fc.assert(
+ fc.property(fc.array(fc.double({ noNaN: true }), { minLength: 1, maxLength: 20 }), (data) => {
+ const series = s(data);
+ const label = idxmaxSeries(series);
+ if (label === null) {
+ return true;
+ }
+ const maxVal = Math.max(...data);
+ return series.at(label as number) === maxVal;
+ }),
+ );
+ });
+
+ it("idxmin and idxmax are consistent — min <= max", () => {
+ fc.assert(
+ fc.property(fc.array(fc.double({ noNaN: true }), { minLength: 2, maxLength: 20 }), (data) => {
+ const series = s(data);
+ const minLabel = idxminSeries(series);
+ const maxLabel = idxmaxSeries(series);
+ if (minLabel === null || maxLabel === null) {
+ return true;
+ }
+ const minVal = series.at(minLabel as number) as number;
+ const maxVal = series.at(maxLabel as number) as number;
+ return minVal <= maxVal;
+ }),
+ );
+ });
+});
From 0a2dd97e053bc7f2bb25477f19b7f89d95275ccc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 13:43:36 +0000
Subject: [PATCH 4/6] =?UTF-8?q?Iteration=20194:=20Add=20astype=20=E2=80=94?=
=?UTF-8?q?=20dtype=20coercion=20for=20Series=20and=20DataFrame?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Run: https://github.com/githubnext/tsessebe/actions/runs/24282208612
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
playground/astype.html | 438 ++++++++++++++++++++++++++++++++++++++
src/core/astype.ts | 245 +++++++++++++++++++++
src/core/index.ts | 2 +
tests/core/astype.test.ts | 292 +++++++++++++++++++++++++
4 files changed, 977 insertions(+)
create mode 100644 playground/astype.html
create mode 100644 src/core/astype.ts
create mode 100644 tests/core/astype.test.ts
diff --git a/playground/astype.html b/playground/astype.html
new file mode 100644
index 00000000..efd9e5ed
--- /dev/null
+++ b/playground/astype.html
@@ -0,0 +1,438 @@
+
+
+
+
+
+ tsb — astype
+
+
+
+
+ replaceSeries / replaceDataFrame substitute values
+ matching a pattern with a new value.
+ Supports scalar, array, and mapping (Record / Map) replacement specs.
+ Mirrors Series.replace() and DataFrame.replace() from pandas.
+
+
+
+
+
1 · Scalar → scalar replacement
+
+ Replace every occurrence of a single value with another value.
+ Works on numbers, strings, booleans, and null.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
2 · Array replacement
+
+ Replace a list of values with a single target, or perform pair-wise
+ replacement using two equal-length arrays.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
3 · Mapping (Record / Map) replacement
+
+ Pass a lookup table as either a plain object (Record<string, Scalar>)
+ or a JavaScript Map for full type flexibility.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+
+
+
+
4 · DataFrame replacement
+
+ replaceDataFrame applies the same spec to all columns by
+ default. Use the columns option to restrict which columns
+ are affected.
+
+
+
+
+
+
diff --git a/src/stats/replace.ts b/src/stats/replace.ts
new file mode 100644
index 00000000..54c2662e
--- /dev/null
+++ b/src/stats/replace.ts
@@ -0,0 +1,237 @@
+/**
+ * replace — value substitution for Series and DataFrame.
+ *
+ * Mirrors the following pandas methods:
+ * - `Series.replace(to_replace, value)` / `Series.replace(mapping)`
+ * - `DataFrame.replace(to_replace, value)` / `DataFrame.replace(mapping)`
+ *
+ * Supported replacement specs:
+ * - **Scalar → Scalar**: replace every occurrence of one value with another.
+ * - **Array → Scalar**: replace every value in the array with a single value.
+ * - **Array → Array**: pair-wise replacement (must be same length).
+ * - **Record / Map**: lookup-table replacement (`{ old: new, ... }`).
+ *
+ * All functions are **pure** (return new objects; inputs are unchanged).
+ *
+ * @module
+ */
+
+import { DataFrame } from "../core/index.ts";
+import { Series } from "../core/index.ts";
+import type { Scalar } from "../types.ts";
+
+// ─── types ────────────────────────────────────────────────────────────────────
+
+/** A lookup table mapping old values to new values. */
+export type ReplaceMapping = Readonly> | ReadonlyMap;
+
+/**
+ * Replacement specification accepted by {@link replaceSeries} /
+ * {@link replaceDataFrame}.
+ *
+ * Mirrors the first two positional args of `pandas.Series.replace`.
+ */
+export type ReplaceSpec =
+ | { readonly toReplace: Scalar; readonly value: Scalar }
+ | { readonly toReplace: readonly Scalar[]; readonly value: Scalar }
+ | { readonly toReplace: readonly Scalar[]; readonly value: readonly Scalar[] }
+ | { readonly toReplace: ReplaceMapping };
+
+/** Options shared by {@link replaceSeries} and {@link replaceDataFrame}. */
+export interface ReplaceOptions {
+ /**
+ * When `true`, treat `NaN` values as equal for matching purposes.
+ * Default `true`.
+ */
+ readonly matchNaN?: boolean;
+}
+
+/** Options for {@link replaceDataFrame}. */
+export interface DataFrameReplaceOptions extends ReplaceOptions {
+ /**
+ * If provided, only replace values in these column names.
+ * By default all columns are processed.
+ */
+ readonly columns?: readonly string[];
+}
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/** True when `a` and `b` are equal (with optional NaN=NaN equality). */
+function scalarEq(a: Scalar, b: Scalar, matchNaN: boolean): boolean {
+ if (
+ matchNaN &&
+ typeof a === "number" &&
+ typeof b === "number" &&
+ Number.isNaN(a) &&
+ Number.isNaN(b)
+ ) {
+ return true;
+ }
+ if (a instanceof Date && b instanceof Date) {
+ return a.getTime() === b.getTime();
+ }
+ return a === b;
+}
+
+/**
+ * Build a replacement function from a {@link ReplaceSpec}.
+ * Returns `(v) => new_value` or `v` unchanged if no match.
+ */
+function buildReplacer(spec: ReplaceSpec, matchNaN: boolean): (v: Scalar) => Scalar {
+ // Mapping variant
+ if (
+ "toReplace" in spec &&
+ !Array.isArray(spec.toReplace) &&
+ typeof spec.toReplace === "object" &&
+ spec.toReplace !== null &&
+ !(spec.toReplace instanceof Map) &&
+ !("value" in spec)
+ ) {
+ // Record
+ const rec = spec.toReplace as Readonly>;
+ return (v: Scalar): Scalar => {
+ const key = String(v);
+ return Object.prototype.hasOwnProperty.call(rec, key) ? (rec[key] as Scalar) : v;
+ };
+ }
+
+ if ("toReplace" in spec && spec.toReplace instanceof Map) {
+ const map = spec.toReplace as ReadonlyMap;
+ return (v: Scalar): Scalar => {
+ for (const [k, val] of map) {
+ if (scalarEq(v, k, matchNaN)) {
+ return val;
+ }
+ }
+ return v;
+ };
+ }
+
+ // Mapping passed via { toReplace: mapping } shape
+ if ("toReplace" in spec && !("value" in spec)) {
+ const mapping = spec.toReplace as ReplaceMapping;
+ if (mapping instanceof Map) {
+ const map = mapping as ReadonlyMap;
+ return (v: Scalar): Scalar => {
+ for (const [k, val] of map) {
+ if (scalarEq(v, k, matchNaN)) {
+ return val;
+ }
+ }
+ return v;
+ };
+ }
+ const rec = mapping as Readonly>;
+ return (v: Scalar): Scalar => {
+ const key = String(v);
+ return Object.prototype.hasOwnProperty.call(rec, key) ? (rec[key] as Scalar) : v;
+ };
+ }
+
+ const s = spec as { toReplace: Scalar | readonly Scalar[]; value: Scalar | readonly Scalar[] };
+
+ if (!Array.isArray(s.toReplace)) {
+ // Scalar → Scalar
+ const old = s.toReplace as Scalar;
+ const newVal = s.value as Scalar;
+ return (v: Scalar): Scalar => (scalarEq(v, old, matchNaN) ? newVal : v);
+ }
+
+ const oldArr = s.toReplace as readonly Scalar[];
+
+ if (!Array.isArray(s.value)) {
+ // Array → Scalar
+ const newVal = s.value as Scalar;
+ return (v: Scalar): Scalar => {
+ for (const old of oldArr) {
+ if (scalarEq(v, old, matchNaN)) {
+ return newVal;
+ }
+ }
+ return v;
+ };
+ }
+
+ // Array → Array (pair-wise)
+ const newArr = s.value as readonly Scalar[];
+ if (oldArr.length !== newArr.length) {
+ throw new RangeError(
+ `replace: toReplace and value arrays must have the same length (got ${oldArr.length} and ${newArr.length})`,
+ );
+ }
+ return (v: Scalar): Scalar => {
+ for (let i = 0; i < oldArr.length; i++) {
+ if (scalarEq(v, oldArr[i] as Scalar, matchNaN)) {
+ return newArr[i] as Scalar;
+ }
+ }
+ return v;
+ };
+}
+
+// ─── Series ───────────────────────────────────────────────────────────────────
+
+/**
+ * Replace values in a Series according to `spec`.
+ *
+ * @example
+ * ```ts
+ * import { Series } from "tsb";
+ * import { replaceSeries } from "tsb";
+ *
+ * const s = new Series({ data: [1, 2, 3, 2, 1] });
+ * const r = replaceSeries(s, { toReplace: 2, value: 99 });
+ * // r.values → [1, 99, 3, 99, 1]
+ * ```
+ */
+export function replaceSeries(
+ series: Series,
+ spec: ReplaceSpec,
+ options: ReplaceOptions = {},
+): Series {
+ const matchNaN = options.matchNaN ?? true;
+ const replacer = buildReplacer(spec, matchNaN);
+ const newData = Array.from({ length: series.size }, (_, i) =>
+ replacer(series.values[i] as Scalar),
+ );
+ return new Series({ data: newData, index: series.index, name: series.name });
+}
+
+// ─── DataFrame ────────────────────────────────────────────────────────────────
+
+/**
+ * Replace values in a DataFrame according to `spec`.
+ *
+ * @example
+ * ```ts
+ * import { DataFrame } from "tsb";
+ * import { replaceDataFrame } from "tsb";
+ *
+ * const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [2, 2, 4] });
+ * const r = replaceDataFrame(df, { toReplace: 2, value: 0 });
+ * // r.col("a").values → [1, 0, 3]
+ * // r.col("b").values → [0, 0, 4]
+ * ```
+ */
+export function replaceDataFrame(
+ df: DataFrame,
+ spec: ReplaceSpec,
+ options: DataFrameReplaceOptions = {},
+): DataFrame {
+ const matchNaN = options.matchNaN ?? true;
+ const replacer = buildReplacer(spec, matchNaN);
+ const targetCols = new Set(options.columns ?? df.columns.values);
+
+ const colMap = new Map>();
+ for (const name of df.columns.values) {
+ const col = df.col(name) as Series;
+ if (targetCols.has(name)) {
+ const newData = Array.from({ length: col.size }, (_, i) => replacer(col.values[i] as Scalar));
+ colMap.set(name, new Series({ data: newData, index: col.index, name: col.name }));
+ } else {
+ colMap.set(name, col);
+ }
+ }
+ return new DataFrame(colMap, df.index);
+}
diff --git a/tests/stats/replace.test.ts b/tests/stats/replace.test.ts
new file mode 100644
index 00000000..452de062
--- /dev/null
+++ b/tests/stats/replace.test.ts
@@ -0,0 +1,246 @@
+/**
+ * Tests for stats/replace — value substitution for Series and DataFrame.
+ */
+
+import { describe, expect, it } from "bun:test";
+import fc from "fast-check";
+import { DataFrame, Series } from "../../src/index.ts";
+import { replaceDataFrame, replaceSeries } from "../../src/stats/replace.ts";
+
+// ─── replaceSeries — scalar → scalar ─────────────────────────────────────────
+
+describe("replaceSeries: scalar → scalar", () => {
+ it("replaces a matching value", () => {
+ const s = new Series({ data: [1, 2, 3, 2, 1] });
+ const r = replaceSeries(s, { toReplace: 2, value: 99 });
+ expect([...r.values]).toEqual([1, 99, 3, 99, 1]);
+ });
+
+ it("leaves non-matching values unchanged", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ const r = replaceSeries(s, { toReplace: 9, value: 0 });
+ expect([...r.values]).toEqual([1, 2, 3]);
+ });
+
+ it("replaces string values", () => {
+ const s = new Series({ data: ["a", "b", "a", "c"] });
+ const r = replaceSeries(s, { toReplace: "a", value: "z" });
+ expect([...r.values]).toEqual(["z", "b", "z", "c"]);
+ });
+
+ it("replaces null values", () => {
+ const s = new Series({ data: [1, null, 3, null] });
+ const r = replaceSeries(s, { toReplace: null, value: 0 });
+ expect([...r.values]).toEqual([1, 0, 3, 0]);
+ });
+
+ it("replaces NaN values when matchNaN=true (default)", () => {
+ const s = new Series({ data: [1, Number.NaN, 3] });
+ const r = replaceSeries(s, { toReplace: Number.NaN, value: 0 });
+ expect([...r.values]).toEqual([1, 0, 3]);
+ });
+
+ it("does NOT replace NaN when matchNaN=false", () => {
+ const s = new Series({ data: [1, Number.NaN, 3] });
+ const r = replaceSeries(s, { toReplace: Number.NaN, value: 0 }, { matchNaN: false });
+ expect(Number.isNaN((r.values[1] as number))).toBe(true);
+ });
+
+ it("preserves index", () => {
+ const s = new Series({ data: [1, 2, 3], index: ["x", "y", "z"] });
+ const r = replaceSeries(s, { toReplace: 2, value: 20 });
+ expect([...r.index.values]).toEqual(["x", "y", "z"]);
+ });
+
+ it("preserves name", () => {
+ const s = new Series({ data: [1, 2], name: "myCol" });
+ const r = replaceSeries(s, { toReplace: 1, value: 0 });
+ expect(r.name).toBe("myCol");
+ });
+
+ it("returns empty series when input is empty", () => {
+ const s = new Series({ data: [] });
+ const r = replaceSeries(s, { toReplace: 1, value: 0 });
+ expect(r.size).toBe(0);
+ });
+});
+
+// ─── replaceSeries — array → scalar ───────────────────────────────────────────
+
+describe("replaceSeries: array → scalar", () => {
+ it("replaces all listed values with single value", () => {
+ const s = new Series({ data: [1, 2, 3, 4, 5] });
+ const r = replaceSeries(s, { toReplace: [1, 3, 5], value: 0 });
+ expect([...r.values]).toEqual([0, 2, 0, 4, 0]);
+ });
+
+ it("handles empty toReplace array", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ const r = replaceSeries(s, { toReplace: [], value: 0 });
+ expect([...r.values]).toEqual([1, 2, 3]);
+ });
+});
+
+// ─── replaceSeries — array → array ────────────────────────────────────────────
+
+describe("replaceSeries: array → array", () => {
+ it("performs pair-wise replacement", () => {
+ const s = new Series({ data: [1, 2, 3, 1, 2] });
+ const r = replaceSeries(s, { toReplace: [1, 2], value: [10, 20] });
+ expect([...r.values]).toEqual([10, 20, 3, 10, 20]);
+ });
+
+ it("throws when array lengths differ", () => {
+ const s = new Series({ data: [1, 2, 3] });
+ expect(() => replaceSeries(s, { toReplace: [1, 2], value: [10] })).toThrow(RangeError);
+ });
+});
+
+// ─── replaceSeries — mapping (Record) ─────────────────────────────────────────
+
+describe("replaceSeries: Record mapping", () => {
+ it("replaces using a Record map", () => {
+ const s = new Series({ data: [1, 2, 3, 4] });
+ const r = replaceSeries(s, { toReplace: { "1": 10, "3": 30 } });
+ expect([...r.values]).toEqual([10, 2, 30, 4]);
+ });
+
+ it("leaves values with no mapping entry unchanged", () => {
+ const s = new Series({ data: ["a", "b", "c"] });
+ const r = replaceSeries(s, { toReplace: { "a": "A" } });
+ expect([...r.values]).toEqual(["A", "b", "c"]);
+ });
+});
+
+// ─── replaceSeries — mapping (Map) ────────────────────────────────────────────
+
+describe("replaceSeries: Map mapping", () => {
+ it("replaces using a Map", () => {
+ const s = new Series({ data: [1, 2, 3, 2, 1] });
+ const map = new Map([[1, 100], [2, 200]]);
+ const r = replaceSeries(s, { toReplace: map });
+ expect([...r.values]).toEqual([100, 200, 3, 200, 100]);
+ });
+
+ it("handles NaN keys in Map with matchNaN=true", () => {
+ const s = new Series({ data: [1, Number.NaN, 3] });
+ const map = new Map([[Number.NaN, 99]]);
+ const r = replaceSeries(s, { toReplace: map });
+ expect([...r.values]).toEqual([1, 99, 3]);
+ });
+});
+
+// ─── replaceDataFrame ─────────────────────────────────────────────────────────
+
+describe("replaceDataFrame: basic", () => {
+ it("replaces value in all columns", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [2, 2, 4] });
+ const r = replaceDataFrame(df, { toReplace: 2, value: 0 });
+ expect([...r.col("a").values]).toEqual([1, 0, 3]);
+ expect([...r.col("b").values]).toEqual([0, 0, 4]);
+ });
+
+ it("restricts replacement to specified columns", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [2, 2, 4] });
+ const r = replaceDataFrame(df, { toReplace: 2, value: 0 }, { columns: ["a"] });
+ expect([...r.col("a").values]).toEqual([1, 0, 3]);
+ expect([...r.col("b").values]).toEqual([2, 2, 4]);
+ });
+
+ it("preserves index", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3] });
+ const r = replaceDataFrame(df, { toReplace: 1, value: 10 });
+ expect([...r.index.values]).toEqual([...df.index.values]);
+ });
+
+ it("preserves columns order", () => {
+ const df = DataFrame.fromColumns({ a: [1], b: [2], c: [3] });
+ const r = replaceDataFrame(df, { toReplace: 1, value: 99 });
+ expect([...r.columns.values]).toEqual(["a", "b", "c"]);
+ });
+
+ it("uses array → scalar replacement across columns", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2, 3], b: [3, 4, 5] });
+ const r = replaceDataFrame(df, { toReplace: [1, 3], value: 0 });
+ expect([...r.col("a").values]).toEqual([0, 2, 0]);
+ expect([...r.col("b").values]).toEqual([0, 4, 5]);
+ });
+
+ it("uses Record mapping across columns", () => {
+ const df = DataFrame.fromColumns({ a: [1, 2], b: [2, 3] });
+ const r = replaceDataFrame(df, { toReplace: { "2": 20 } });
+ expect([...r.col("a").values]).toEqual([1, 20]);
+ expect([...r.col("b").values]).toEqual([20, 3]);
+ });
+});
+
+// ─── property-based tests ─────────────────────────────────────────────────────
+
+describe("replaceSeries: properties", () => {
+ it("scalar→scalar: replaced value never appears where original matched", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 0, maxLength: 20 }),
+ fc.integer({ min: 0, max: 9 }),
+ fc.integer({ min: 10, max: 99 }),
+ (data, old, newVal) => {
+ const s = new Series({ data });
+ const r = replaceSeries(s, { toReplace: old, value: newVal });
+ for (let i = 0; i < s.size; i++) {
+ if (s.values[i] === old) {
+ if (r.values[i] !== newVal) return false;
+ } else {
+ if (r.values[i] !== s.values[i]) return false;
+ }
+ }
+ return true;
+ },
+ ),
+ );
+ });
+
+ it("size is preserved", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.integer({ min: 0, max: 9 }), { minLength: 0, maxLength: 30 }),
+ (data) => {
+ const s = new Series({ data });
+ const r = replaceSeries(s, { toReplace: 5, value: 0 });
+ return r.size === s.size;
+ },
+ ),
+ );
+ });
+
+ it("no-op when toReplace not present", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.integer({ min: 0, max: 5 }), { minLength: 1, maxLength: 20 }),
+ (data) => {
+ const s = new Series({ data });
+ // 99 is never in the array since data is 0-5
+ const r = replaceSeries(s, { toReplace: 99, value: -1 });
+ return [...r.values].every((v, i) => v === data[i]);
+ },
+ ),
+ );
+ });
+
+ it("array→array: pair-wise replacement is consistent", () => {
+ fc.assert(
+ fc.property(
+ fc.array(fc.integer({ min: 0, max: 5 }), { minLength: 0, maxLength: 20 }),
+ (data) => {
+ const s = new Series({ data });
+ const r = replaceSeries(s, { toReplace: [1, 2, 3], value: [10, 20, 30] });
+ const mapping: Record = { 1: 10, 2: 20, 3: 30 };
+ return [...r.values].every((v, i) => {
+ const orig = data[i] as number;
+ const expected = mapping[orig] ?? orig;
+ return v === expected;
+ });
+ },
+ ),
+ );
+ });
+});
From 98bdd69d0f4a8f54141cb8ce123b4176329c3cdb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 11 Apr 2026 13:43:38 +0000
Subject: [PATCH 6/6] =?UTF-8?q?Iteration=20196:=20Add=20where/mask=20?=
=?UTF-8?q?=E2=80=94=20conditional=20value=20selection=20for=20Series=20an?=
=?UTF-8?q?d=20DataFrame?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Run: https://github.com/githubnext/tsessebe/actions/runs/24283415842
---
playground/where_mask.html | 199 +++++++++++++++
src/stats/where_mask.ts | 430 +++++++++++++++++++++++++++++++++
tests/stats/where_mask.test.ts | 328 +++++++++++++++++++++++++
3 files changed, 957 insertions(+)
create mode 100644 playground/where_mask.html
create mode 100644 src/stats/where_mask.ts
create mode 100644 tests/stats/where_mask.test.ts
diff --git a/playground/where_mask.html b/playground/where_mask.html
new file mode 100644
index 00000000..8e3bba6a
--- /dev/null
+++ b/playground/where_mask.html
@@ -0,0 +1,199 @@
+
+
+
+
+
+ tsb — where / mask
+
+
+
+
tsb — where / mask
+
+ Conditional value selection: keep or replace elements based on a boolean
+ condition. These are the TypeScript equivalents of
+ pandas.Series.where / pandas.DataFrame.where and
+ pandas.Series.mask / pandas.DataFrame.mask.
+
+
+
Core concept
+
// where: keep where cond=true, replace with `other` where cond=false
+whereSeries(s, cond, { other: null })
+
+// mask: replace where cond=true with `other`, keep where cond=false
+maskSeries(s, cond, { other: null })