diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..d9fd424d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - "autoloop/**"
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Test & Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Type check
+ run: bun run typecheck
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Test
+ run: bun test --coverage
+
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ needs: test
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Build library
+ run: bun build ./src/index.ts --outdir ./dist --target browser --minify
+
+ - name: Upload dist artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: dist
+ path: dist/
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 00000000..5b9009b0
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,54 @@
+name: Deploy Playground to Pages
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build Playground
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Build library for browser
+ run: bun build ./src/index.ts --outdir ./playground/dist --target browser --minify
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: playground/
+
+ deploy:
+ name: Deploy to Pages
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..4088a0f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+*.tsbuildinfo
+package-lock.json
+*.tgz
diff --git a/.vscode/mcp.json b/.vscode/mcp.json
index 96e7285c..01021df6 100644
--- a/.vscode/mcp.json
+++ b/.vscode/mcp.json
@@ -2,10 +2,7 @@
"servers": {
"github-agentic-workflows": {
"command": "gh",
- "args": [
- "aw",
- "mcp-server"
- ]
+ "args": ["aw", "mcp-server"]
}
}
-}
\ No newline at end of file
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index dbd4bd79..11d9bacd 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,5 @@
{
- "github.copilot.enable": {
- "markdown": true
- }
-}
\ No newline at end of file
+ "github.copilot.enable": {
+ "markdown": true
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..44c24676
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,65 @@
+# Agent Instructions (AGENTS.md)
+
+This file provides project-specific conventions for AI coding agents working in this repository.
+
+## Project Overview
+
+**tsb** is a TypeScript port of [pandas](https://pandas.pydata.org/), built from first principles.
+- Package name: `tsb` — all imports use `tsb`
+- Runtime: Bun
+- Language: TypeScript (strictest mode)
+
+## Key Rules
+
+1. **Never modify `README.md`** — it is read-only, the source of truth for project parameters.
+2. **Never modify `.autoloop/programs/**`** or autoloop workflow files.
+3. **Strict TypeScript only** — no `any`, no `as` casts, no `@ts-ignore`, no escape hatches.
+4. **Zero core dependencies** — implement everything from scratch.
+5. **100% test coverage** required — unit + property-based (fast-check) + fuzz where applicable.
+6. **Every feature gets a playground page** in `playground/`.
+7. **One feature per commit** — keep changes small and targeted.
+
+## Project Structure
+
+```
+src/
+ index.ts — package entry point, re-exports all features
+ types.ts — shared type definitions
+ core/ — core data structures (Series, DataFrame, Index, Dtype)
+ io/ — I/O utilities (read_csv, read_json, etc.)
+ groupby/ — groupby and aggregation
+ reshape/ — pivot, melt, stack, unstack
+ merge/ — merge, join, concat
+ window/ — rolling, expanding, ewm
+ stats/ — statistical functions
+tests/
+ setup.ts — global test setup (loaded via bunfig.toml)
+ *.test.ts — mirrors src/ structure
+playground/
+ index.html — landing page
+ *.html — one page per feature
+```
+
+## Adding a New Feature
+
+1. Create `src/{module}/{feature}.ts` with the implementation.
+2. Export from `src/index.ts`.
+3. Create `tests/{module}/{feature}.test.ts` with full coverage.
+4. Create `playground/{feature}.html` with an interactive tutorial.
+5. Update `playground/index.html` to mark the feature as complete.
+
+## Running Locally
+
+```bash
+bun install # install devDependencies
+bun test # run all tests
+bun run lint # check linting
+bun run typecheck # TypeScript strict check
+```
+
+## Autoloop Coordination
+
+This project is built by [Autoloop](https://github.com/githubnext/autoloop), an iterative optimization agent.
+- Long-running branch: `autoloop/build-tsb-pandas-typescript-migration`
+- State file: `build-tsb-pandas-typescript-migration.md` on `memory/autoloop` branch
+- Issue #1 is the program definition — do not modify it.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..adf094b4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,27 @@
+---
+description: Coding preferences for Claude when working on tsb.
+---
+
+# Claude Code Configuration (CLAUDE.md)
+
+## Behavior
+
+- Always read `AGENTS.md` first for project conventions.
+- Read `README.md` to understand the project requirements — treat it as read-only.
+- Read the state file in `.autoloop/memory/` for current migration progress.
+
+## Code Style
+
+- TypeScript strict mode — no `any`, no `as`, no `@ts-ignore`
+- Biome formatting (spaces, 100-col lines, double quotes, trailing commas)
+- JSDoc for all exported symbols
+- Unit tests with `bun:test` + property tests with `fast-check`
+
+## Commands
+
+```bash
+bun install # install deps
+bun test # run tests
+bun run lint # Biome lint
+bun run typecheck # tsc --noEmit
+```
diff --git a/biome.json b/biome.json
new file mode 100644
index 00000000..4c06c454
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false,
+ "ignore": ["dist/**", "node_modules/**", "*.d.ts"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "complexity": {
+ "all": true
+ },
+ "correctness": {
+ "all": true
+ },
+ "nursery": {
+ "all": true
+ },
+ "performance": {
+ "all": true,
+ "noBarrelFile": "off"
+ },
+ "security": {
+ "all": true
+ },
+ "style": {
+ "all": true,
+ "noDefaultExport": "off",
+ "useNamingConvention": "off"
+ },
+ "suspicious": {
+ "all": true
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "trailingCommas": "all",
+ "semicolons": "always"
+ }
+ },
+ "overrides": [
+ {
+ "include": ["**/*.ts", "**/*.tsx"],
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double",
+ "trailingCommas": "all",
+ "semicolons": "always"
+ }
+ }
+ }
+ ]
+}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 00000000..8f9aee13
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,6 @@
+[test]
+preload = ["./tests/setup.ts"]
+coverage = true
+
+[install]
+exact = true
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..418b15e0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "tsb",
+ "version": "0.0.1",
+ "description": "A TypeScript port of pandas, built from first principles",
+ "type": "module",
+ "main": "./src/index.ts",
+ "module": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "test": "bun test",
+ "lint": "biome check .",
+ "lint:fix": "biome check --write .",
+ "typecheck": "tsc --noEmit",
+ "build": "bun build ./src/index.ts --outdir ./dist --target browser",
+ "playground": "bun run playground/serve.ts"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.4",
+ "fast-check": "^3.22.0",
+ "@types/bun": "^1.1.14"
+ },
+ "peerDependencies": {
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/playground/index-playground.html b/playground/index-playground.html
new file mode 100644
index 00000000..3f1e5345
--- /dev/null
+++ b/playground/index-playground.html
@@ -0,0 +1,203 @@
+
+
+
+
+
+ tsb — Index & RangeIndex Playground
+
+
+
+ ← Back to roadmap
+ 🏷️ Index & RangeIndex
+
+ The Index type is the immutable, ordered sequence of labels
+ that underpins both Series (row axis) and DataFrame
+ (row + column axes). RangeIndex is a memory-efficient subclass
+ for integer ranges.
+
+
+
+
Creating an Index
+
+import { Index, RangeIndex } from "tsb";
+
+// String labels
+const labels = new Index(["a", "b", "c", "d"], "letters");
+// → Index([a, b, c, d], name='letters')
+
+// Numeric labels
+const nums = new Index([10, 20, 30]);
+// → Index([10, 20, 30])
+
+// RangeIndex (memory-efficient integer range)
+const range = new RangeIndex(5);
+// → RangeIndex(start=0, stop=5, step=1) → [0, 1, 2, 3, 4]
+
+const stepped = new RangeIndex(0, 10, 2);
+// → RangeIndex(start=0, stop=10, step=2) → [0, 2, 4, 6, 8]
+
+
+
+
+
Properties
+
+const idx = new Index(["x", "y", "z"], "axis");
+
+idx.size // 3
+idx.shape // [3]
+idx.ndim // 1
+idx.empty // false
+idx.name // "axis"
+idx.isUnique // true
+idx.hasDuplicates // false
+idx.isMonotonicIncreasing // true (x < y < z)
+
+
+
+
+
Label Look-up
+
+const idx = new Index(["a", "b", "c", "a"]);
+
+idx.getLoc("b") // 1 (unique → single int)
+idx.getLoc("a") // [0, 3] (duplicated → array)
+idx.contains("c") // true
+idx.isin(["a", "c"]) // [true, false, true, true]
+
+
+
+
+
Set Operations
+
+const a = new Index([1, 2, 3]);
+const b = new Index([2, 3, 4]);
+
+a.union(b) // Index([1, 2, 3, 4])
+a.intersection(b) // Index([2, 3])
+a.difference(b) // Index([1])
+a.symmetricDifference(b) // Index([1, 4])
+
+
+
+
+
Sorting & Aggregation
+
+const idx = new Index([30, 10, 20]);
+
+idx.sortValues() // Index([10, 20, 30])
+idx.argsort() // [1, 2, 0]
+idx.min() // 10
+idx.max() // 30
+idx.argmin() // 1
+idx.argmax() // 0
+
+
+
+
+
Manipulation (immutable — always returns new Index)
+
+const idx = new Index(["a", "b", "c"]);
+
+idx.append(new Index(["d", "e"])) // Index([a, b, c, d, e])
+idx.insert(1, "x") // Index([a, x, b, c])
+idx.delete(0) // Index([b, c])
+idx.drop(["b"]) // Index([a, c])
+idx.rename("new_name") // Index([a, b, c], name='new_name')
+
+
+
+
+
Missing Values
+
+const idx = new Index([1, null, 3]);
+
+idx.isna() // [false, true, false]
+idx.notna() // [true, false, true]
+idx.dropna() // Index([1, 3])
+idx.fillna(0) // Index([1, 0, 3])
+
+
+
+
+
RangeIndex — Memory Efficient
+
+// Only stores start/stop/step — values computed on the fly
+const r = new RangeIndex(0, 1_000_000);
+r.size // 1000000
+r.at(500) // 500
+
+// Negative step
+const desc = new RangeIndex(10, 0, -2);
+desc.toArray() // [10, 8, 6, 4, 2]
+
+// Slicing preserves RangeIndex type
+r.slice(10, 20) // RangeIndex(start=10, stop=20, step=1)
+
+
+
+
+
+
diff --git a/playground/index.html b/playground/index.html
new file mode 100644
index 00000000..1be4bfdc
--- /dev/null
+++ b/playground/index.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+ tsb — TypeScript pandas | Interactive Playground
+
+
+
+
+
+
+
+ 🚧 Under Construction — Foundation Phase
+ pandas for TypeScript
+
+ tsb is a ground-up TypeScript implementation of the pandas data
+ manipulation library, with full API parity, strict types, and an interactive
+ playground for every feature.
+
+
+
+
+ Feature Roadmap
+
+
+
📐 Project Foundation
+
Bun, TypeScript (strict), Biome linting, CI, Pages deployment, type system.
+
✅ Complete
+
+
+
📊 Series
+
1-D labeled array — the core building block of tsb data structures.
+
⏳ Planned
+
+
+
🗃️ DataFrame
+
2-D labeled table with heterogeneous columns, the heart of pandas.
+
⏳ Planned
+
+
+
🏷️ Index
+
Immutable labeled axis — Index<T> , RangeIndex.
+
✅ Complete
+
+
+
🔢 Dtypes
+
Rich dtype system: int/float/bool/string/datetime/category.
+
⏳ Planned
+
+
+
📥 I/O
+
read_csv, read_json, read_parquet, to_csv, to_json.
+
⏳ Planned
+
+
+
+
+
+
+
+
diff --git a/src/core/base-index.ts b/src/core/base-index.ts
new file mode 100644
index 00000000..efb7018c
--- /dev/null
+++ b/src/core/base-index.ts
@@ -0,0 +1,568 @@
+/**
+ * Generic Index — the immutable, labeled axis for Series and DataFrame.
+ *
+ * Mirrors pandas.Index: stores an ordered sequence of labels,
+ * supports set operations, duplicate detection, look-up by label, and more.
+ */
+
+import type { Label } from "../types.ts";
+
+/** Options accepted by the Index constructor. */
+export interface IndexOptions {
+ readonly data: readonly T[];
+ readonly name?: string | null;
+}
+
+/**
+ * An immutable, ordered sequence of labels.
+ *
+ * `Index` is the TypeScript equivalent of `pandas.Index`.
+ * It underpins both `Series` (as the row axis) and `DataFrame`
+ * (as the row axis *and* column axis).
+ */
+export class Index {
+ /** Internal storage — never exposed mutably. */
+ protected readonly _values: readonly T[];
+
+ /** Optional human-readable label for this axis. */
+ readonly name: string | null;
+
+ // ─── construction ───────────────────────────────────────────────
+
+ constructor(data: readonly T[], name?: string | null) {
+ this._values = Object.freeze([...data]);
+ this.name = name ?? null;
+ }
+
+ /**
+ * Factory that accepts the `IndexOptions` bag.
+ * Useful when forwarding options from higher-level constructors.
+ */
+ static from(opts: IndexOptions): Index {
+ return new Index(opts.data, opts.name);
+ }
+
+ // ─── properties ─────────────────────────────────────────────────
+
+ /** Number of elements. */
+ get size(): number {
+ return this._values.length;
+ }
+
+ /** Shape tuple (always 1-D). */
+ get shape(): [number] {
+ return [this._values.length];
+ }
+
+ /** Number of dimensions (always 1). */
+ get ndim(): 1 {
+ return 1;
+ }
+
+ /** True when the index has zero elements. */
+ get empty(): boolean {
+ return this._values.length === 0;
+ }
+
+ /** Snapshot of the underlying values as a plain array. */
+ get values(): readonly T[] {
+ return this._values;
+ }
+
+ /** True when every label appears exactly once. */
+ get isUnique(): boolean {
+ return new Set(this._values).size === this._values.length;
+ }
+
+ /** True when any label appears more than once. */
+ get hasDuplicates(): boolean {
+ return !this.isUnique;
+ }
+
+ /** True when values are weakly ascending. */
+ get isMonotonicIncreasing(): boolean {
+ for (let i = 1; i < this._values.length; i++) {
+ const prev = this._values[i - 1];
+ const curr = this._values[i];
+ if (prev === undefined || curr === undefined || prev === null || curr === null) {
+ return false;
+ }
+ if (prev > curr) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** True when values are weakly descending. */
+ get isMonotonicDecreasing(): boolean {
+ for (let i = 1; i < this._values.length; i++) {
+ const prev = this._values[i - 1];
+ const curr = this._values[i];
+ if (prev === undefined || curr === undefined || prev === null || curr === null) {
+ return false;
+ }
+ if (prev < curr) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // ─── element access ─────────────────────────────────────────────
+
+ /** Return the label at positional index `i`. */
+ at(i: number): T {
+ const len = this._values.length;
+ const idx = i < 0 ? len + i : i;
+ if (idx < 0 || idx >= len) {
+ throw new RangeError(`Index ${i} is out of bounds for axis of size ${len}`);
+ }
+ return this._values[idx] as T;
+ }
+
+ /** Return a new Index from a positional slice [start, end). */
+ slice(start?: number, end?: number): Index {
+ return new Index(this._values.slice(start, end), this.name);
+ }
+
+ /**
+ * Fancy-index: return a new Index by picking positions from `indices`.
+ */
+ take(indices: readonly number[]): Index {
+ const out: T[] = [];
+ for (const i of indices) {
+ out.push(this.at(i));
+ }
+ return new Index(out, this.name);
+ }
+
+ // ─── look-up ────────────────────────────────────────────────────
+
+ /**
+ * Return the integer position of `key`.
+ *
+ * - If `key` appears exactly once, returns a single `number`.
+ * - If `key` appears more than once, returns an array of positions.
+ * - If `key` is absent, throws.
+ */
+ getLoc(key: Label): number | readonly number[] {
+ const positions: number[] = [];
+ for (let i = 0; i < this._values.length; i++) {
+ if (this._values[i] === key) {
+ positions.push(i);
+ }
+ }
+ if (positions.length === 0) {
+ throw new Error(`KeyError: ${String(key)}`);
+ }
+ if (positions.length === 1) {
+ return positions[0] as number;
+ }
+ return positions;
+ }
+
+ /**
+ * Compute an indexer array for `target` against this index.
+ * Each position in the returned array corresponds to a label in `target`:
+ * - its position in `this`, or
+ * - `-1` if not found.
+ */
+ getIndexer(target: Index): readonly number[] {
+ const map = new Map();
+ for (let i = 0; i < this._values.length; i++) {
+ const v = this._values[i] as T;
+ if (!map.has(v)) {
+ map.set(v, i);
+ }
+ }
+ return target._values.map((v) => map.get(v) ?? -1);
+ }
+
+ /** True when `item` exists in this index. */
+ contains(item: Label): boolean {
+ return this._values.some((v) => v === item);
+ }
+
+ /**
+ * Boolean mask: `true` at each position whose label is in `items`.
+ */
+ isin(items: readonly T[]): readonly boolean[] {
+ const set = new Set(items);
+ return this._values.map((v) => set.has(v));
+ }
+
+ // ─── set operations ─────────────────────────────────────────────
+
+ /** Return the union of this and `other`. */
+ union(other: Index): Index {
+ const seen = new Set();
+ const out: (T | U)[] = [];
+ for (const v of this._values) {
+ if (!seen.has(v)) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ for (const v of other._values) {
+ if (!seen.has(v)) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ /** Return elements common to both indices. */
+ intersection(other: Index): Index {
+ const otherSet = new Set(other._values);
+ const seen = new Set();
+ const out: (T | U)[] = [];
+ for (const v of this._values) {
+ if (otherSet.has(v) && !seen.has(v)) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ /** Return elements in `this` but not in `other`. */
+ difference(other: Index): Index {
+ const otherSet = new Set(other._values);
+ const seen = new Set();
+ const out: T[] = [];
+ for (const v of this._values) {
+ if (!(otherSet.has(v) || seen.has(v))) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ /** Return elements in either index but not in both. */
+ symmetricDifference(other: Index): Index {
+ const thisSet = new Set(this._values);
+ const otherSet = new Set(other._values);
+ const seen = new Set();
+ const out: (T | U)[] = [];
+ for (const v of this._values) {
+ if (!(otherSet.has(v) || seen.has(v))) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ for (const v of other._values) {
+ if (!(thisSet.has(v) || seen.has(v))) {
+ seen.add(v);
+ out.push(v);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ // ─── duplicate handling ─────────────────────────────────────────
+
+ /**
+ * Boolean mask flagging duplicate labels.
+ *
+ * @param keep `"first"` keeps the first occurrence unmarked,
+ * `"last"` keeps the last occurrence unmarked,
+ * `false` marks all duplicates.
+ */
+ duplicated(keep: "first" | "last" | false = "first"): readonly boolean[] {
+ if (keep === "first") {
+ return this.duplicatedKeepFirst();
+ }
+ if (keep === "last") {
+ return this.duplicatedKeepLast();
+ }
+ return this.duplicatedKeepNone();
+ }
+
+ private duplicatedKeepFirst(): readonly boolean[] {
+ const seen = new Set();
+ return this._values.map((v) => {
+ if (seen.has(v)) {
+ return true;
+ }
+ seen.add(v);
+ return false;
+ });
+ }
+
+ private duplicatedKeepLast(): readonly boolean[] {
+ const seen = new Set();
+ const result = new Array(this._values.length).fill(false);
+ for (let i = this._values.length - 1; i >= 0; i--) {
+ const v = this._values[i] as T;
+ if (seen.has(v)) {
+ result[i] = true;
+ } else {
+ seen.add(v);
+ }
+ }
+ return result;
+ }
+
+ private duplicatedKeepNone(): readonly boolean[] {
+ const counts = new Map();
+ for (const v of this._values) {
+ counts.set(v, (counts.get(v) ?? 0) + 1);
+ }
+ return this._values.map((v) => (counts.get(v) ?? 0) > 1);
+ }
+
+ /** Return a new Index with duplicates removed. */
+ dropDuplicates(keep: "first" | "last" = "first"): Index {
+ const mask = this.duplicated(keep);
+ const out: T[] = [];
+ for (let i = 0; i < this._values.length; i++) {
+ if (!mask[i]) {
+ out.push(this._values[i] as T);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ /** Count of unique labels. */
+ nunique(): number {
+ return new Set(this._values).size;
+ }
+
+ // ─── manipulation ───────────────────────────────────────────────
+
+ /** Concatenate one or more indices. */
+ append(other: Index | readonly Index[]): Index {
+ const others = Array.isArray(other) ? other : [other];
+ let combined: (T | U)[] = [...this._values];
+ for (const o of others) {
+ combined = combined.concat([...o._values]);
+ }
+ return new Index(combined, this.name);
+ }
+
+ /** Return a new Index with `item` inserted at position `loc`. */
+ insert(loc: number, item: U): Index {
+ const out: (T | U)[] = [...this._values];
+ out.splice(loc, 0, item);
+ return new Index(out, this.name);
+ }
+
+ /** Return a new Index with position(s) removed. */
+ delete(loc: number | readonly number[]): Index {
+ const positions = new Set(typeof loc === "number" ? [loc] : loc);
+ const out: T[] = [];
+ for (let i = 0; i < this._values.length; i++) {
+ if (!positions.has(i)) {
+ out.push(this._values[i] as T);
+ }
+ }
+ return new Index(out, this.name);
+ }
+
+ /** Return a new Index with the given labels removed. */
+ drop(labels: readonly T[]): Index {
+ const toDrop = new Set(labels);
+ return new Index(
+ this._values.filter((v) => !toDrop.has(v)),
+ this.name,
+ );
+ }
+
+ /** Return a shallow copy, optionally with a new name. */
+ copy(name?: string | null): Index {
+ return new Index([...this._values], name === undefined ? this.name : name);
+ }
+
+ /** Return a new Index with a different name. */
+ rename(name: string | null): Index {
+ return new Index(this._values, name);
+ }
+
+ // ─── comparison ─────────────────────────────────────────────────
+
+ /** True when the *values* of two indices match element-wise (ignores name). */
+ equals(other: Index): boolean {
+ if (this._values.length !== other._values.length) {
+ return false;
+ }
+ for (let i = 0; i < this._values.length; i++) {
+ if (this._values[i] !== other._values[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** True when both *values* and *name* are identical. */
+ identical(other: Index): boolean {
+ return this.name === other.name && this.equals(other);
+ }
+
+ // ─── conversion ─────────────────────────────────────────────────
+
+ /** Return the labels as a plain mutable array. */
+ toArray(): T[] {
+ return [...this._values];
+ }
+
+ /** Alias for `toArray()` — mirrors `pandas.Index.tolist()`. */
+ toList(): T[] {
+ return this.toArray();
+ }
+
+ // ─── aggregation ────────────────────────────────────────────────
+
+ /** Return the minimum label (null-safe). */
+ min(): T | undefined {
+ if (this._values.length === 0) {
+ return undefined;
+ }
+ let best: T = this._values[0] as T;
+ for (let i = 1; i < this._values.length; i++) {
+ const v = this._values[i] as T;
+ if (best === null || (v !== null && v < best)) {
+ best = v;
+ }
+ }
+ return best;
+ }
+
+ /** Return the maximum label (null-safe). */
+ max(): T | undefined {
+ if (this._values.length === 0) {
+ return undefined;
+ }
+ let best: T = this._values[0] as T;
+ for (let i = 1; i < this._values.length; i++) {
+ const v = this._values[i] as T;
+ if (best === null || (v !== null && v > best)) {
+ best = v;
+ }
+ }
+ return best;
+ }
+
+ /** Return the position of the minimum label. */
+ argmin(): number {
+ if (this._values.length === 0) {
+ throw new Error("argmin requires a non-empty Index");
+ }
+ let bestIdx = 0;
+ let best: T = this._values[0] as T;
+ for (let i = 1; i < this._values.length; i++) {
+ const v = this._values[i] as T;
+ if (best === null || (v !== null && v < best)) {
+ best = v;
+ bestIdx = i;
+ }
+ }
+ return bestIdx;
+ }
+
+ /** Return the position of the maximum label. */
+ argmax(): number {
+ if (this._values.length === 0) {
+ throw new Error("argmax requires a non-empty Index");
+ }
+ let bestIdx = 0;
+ let best: T = this._values[0] as T;
+ for (let i = 1; i < this._values.length; i++) {
+ const v = this._values[i] as T;
+ if (best === null || (v !== null && v > best)) {
+ best = v;
+ bestIdx = i;
+ }
+ }
+ return bestIdx;
+ }
+
+ /** Return the integer permutation that would sort this index ascending. */
+ argsort(): readonly number[] {
+ const indices = Array.from({ length: this._values.length }, (_, i) => i);
+ indices.sort((a, b) => {
+ const va = this._values[a];
+ const vb = this._values[b];
+ if (va === vb) {
+ return 0;
+ }
+ if (va === undefined || va === null) {
+ return 1;
+ }
+ if (vb === undefined || vb === null) {
+ return -1;
+ }
+ return va < vb ? -1 : 1;
+ });
+ return indices;
+ }
+
+ /** Return a new Index with values sorted ascending. */
+ sortValues(ascending = true): Index {
+ const sorted = [...this._values].sort((a, b) => {
+ if (a === b) {
+ return 0;
+ }
+ if (a === null) {
+ return 1;
+ }
+ if (b === null) {
+ return -1;
+ }
+ const cmp = a < b ? -1 : 1;
+ return ascending ? cmp : -cmp;
+ });
+ return new Index(sorted, this.name);
+ }
+
+ // ─── missing-value helpers ──────────────────────────────────────
+
+ /** Boolean mask: `true` where the label is `null`. */
+ isna(): readonly boolean[] {
+ return this._values.map((v) => v === null);
+ }
+
+ /** Boolean mask: `true` where the label is not `null`. */
+ notna(): readonly boolean[] {
+ return this._values.map((v) => v !== null);
+ }
+
+ /** Return a new Index with `null` labels removed. */
+ dropna(): Index {
+ return new Index(
+ this._values.filter((v): v is T => v !== null),
+ this.name,
+ );
+ }
+
+ /** Replace `null` labels with `value`. */
+ fillna(value: U): Index {
+ return new Index(
+ this._values.map((v) => (v === null ? value : v)),
+ this.name,
+ );
+ }
+
+ // ─── iteration / misc ──────────────────────────────────────────
+
+ /** Allow `for…of` iteration. */
+ *[Symbol.iterator](): Generator {
+ for (const v of this._values) {
+ yield v;
+ }
+ }
+
+ /** Pretty-print representation. */
+ toString(): string {
+ const vals = this._values.map(String).join(", ");
+ const nameStr = this.name !== null ? `, name='${this.name}'` : "";
+ return `Index([${vals}]${nameStr})`;
+ }
+
+ /** Return a new Index by applying `fn` to each label. */
+ map(fn: (value: T, index: number) => U): Index {
+ return new Index(this._values.map(fn), this.name);
+ }
+}
diff --git a/src/core/index.ts b/src/core/index.ts
new file mode 100644
index 00000000..16265d50
--- /dev/null
+++ b/src/core/index.ts
@@ -0,0 +1,3 @@
+export { Index } from "./base-index.ts";
+export type { IndexOptions } from "./base-index.ts";
+export { RangeIndex } from "./range-index.ts";
diff --git a/src/core/range-index.ts b/src/core/range-index.ts
new file mode 100644
index 00000000..97f1cf10
--- /dev/null
+++ b/src/core/range-index.ts
@@ -0,0 +1,130 @@
+/**
+ * RangeIndex — a memory-efficient integer index backed by start/stop/step.
+ *
+ * Mirrors pandas.RangeIndex: stores only the range parameters,
+ * expanding to actual values only when required.
+ */
+
+import { Index } from "./base-index.ts";
+
+/**
+ * A memory-efficient index representing a monotonic integer range.
+ *
+ * Only `start`, `stop`, and `step` are stored; individual values are
+ * computed on the fly. This is the default index type assigned to
+ * Series and DataFrames when no explicit index is provided.
+ *
+ * @example
+ * ```ts
+ * const r = new RangeIndex(5); // 0, 1, 2, 3, 4
+ * const r2 = new RangeIndex(0, 10, 2); // 0, 2, 4, 6, 8
+ * ```
+ */
+export class RangeIndex extends Index {
+ readonly start: number;
+ readonly stop: number;
+ readonly step: number;
+
+ // ─── construction ───────────────────────────────────────────────
+
+ /**
+ * Create a new `RangeIndex`.
+ *
+ * Follows the same overload convention as Python's `range()`:
+ *
+ * - `new RangeIndex(stop)` → `[0, 1, …, stop-1]`
+ * - `new RangeIndex(start, stop)` → `[start, start+1, …, stop-1]`
+ * - `new RangeIndex(start, stop, step)` → `[start, start+step, …]`
+ */
+ constructor(startOrStop: number, stop?: number, step?: number, name?: string | null) {
+ const resolvedStart = stop === undefined ? 0 : startOrStop;
+ const resolvedStop = stop === undefined ? startOrStop : stop;
+ const resolvedStep = step ?? 1;
+
+ if (resolvedStep === 0) {
+ throw new RangeError("RangeIndex step must not be zero");
+ }
+
+ const values = RangeIndex.computeValues(resolvedStart, resolvedStop, resolvedStep);
+ super(values, name);
+
+ this.start = resolvedStart;
+ this.stop = resolvedStop;
+ this.step = resolvedStep;
+ }
+
+ // ─── internal helpers ───────────────────────────────────────────
+
+ private static computeValues(start: number, stop: number, step: number): number[] {
+ const out: number[] = [];
+ if (step > 0) {
+ for (let v = start; v < stop; v += step) {
+ out.push(v);
+ }
+ } else {
+ for (let v = start; v > stop; v += step) {
+ out.push(v);
+ }
+ }
+ return out;
+ }
+
+ // ─── overridden properties ──────────────────────────────────────
+
+ /** A RangeIndex is always unique. */
+ override get isUnique(): true {
+ return true;
+ }
+
+ /** A RangeIndex never has duplicates. */
+ override get hasDuplicates(): false {
+ return false;
+ }
+
+ /** Monotonicity depends on step direction and non-emptiness. */
+ override get isMonotonicIncreasing(): boolean {
+ return this.size <= 1 || this.step > 0;
+ }
+
+ override get isMonotonicDecreasing(): boolean {
+ return this.size <= 1 || this.step < 0;
+ }
+
+ // ─── slicing (returns RangeIndex when possible) ─────────────────
+
+ override slice(start?: number, end?: number): RangeIndex {
+ const sliced = this._values.slice(start, end);
+ if (sliced.length === 0) {
+ return new RangeIndex(0, 0, 1, this.name);
+ }
+ const first = sliced[0] as number;
+ if (sliced.length === 1) {
+ return new RangeIndex(
+ first,
+ first + (this.step > 0 ? 1 : -1),
+ this.step > 0 ? 1 : -1,
+ this.name,
+ );
+ }
+ const newStep = (sliced[1] as number) - first;
+ const last = sliced.at(-1) as number;
+ return new RangeIndex(first, last + (newStep > 0 ? 1 : -1), newStep, this.name);
+ }
+
+ /** Return a shallow copy, optionally with a new name. */
+ override copy(name?: string | null): RangeIndex {
+ return new RangeIndex(this.start, this.stop, this.step, name === undefined ? this.name : name);
+ }
+
+ /** Return a new RangeIndex with a different name. */
+ override rename(name: string | null): RangeIndex {
+ return new RangeIndex(this.start, this.stop, this.step, name);
+ }
+
+ // ─── pretty-print ──────────────────────────────────────────────
+
+ override toString(): string {
+ const nameStr = this.name !== null ? `, name='${this.name}'` : "";
+ return `RangeIndex(start=${this.start}, stop=${this.stop}, step=${this.step}${nameStr})`;
+ }
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 00000000..978717d4
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,31 @@
+/**
+ * tsb — A TypeScript port of pandas, built from first principles.
+ *
+ * @packageDocumentation
+ */
+
+// Core exports will be added here as features are implemented.
+// Each module is imported and re-exported from its feature file in src/.
+//
+// Planned export structure (mirrors pandas top-level API):
+// export * from "./core/frame.ts"; // DataFrame
+// export * from "./core/series.ts"; // Series
+// export * from "./core/dtypes.ts"; // Dtype system
+// export * from "./io/read_csv.ts"; // I/O utilities
+// ... (see .autoloop/memory/migration-plan.md for full roadmap)
+
+export const TSB_VERSION = "0.0.1";
+
+export type {
+ Axis,
+ DtypeName,
+ FillMethod,
+ JoinHow,
+ Label,
+ Scalar,
+ SortOrder,
+} from "./types.ts";
+
+export { Index } from "./core/index.ts";
+export type { IndexOptions } from "./core/index.ts";
+export { RangeIndex } from "./core/index.ts";
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 00000000..34916738
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,44 @@
+/**
+ * Core type definitions shared across tsb.
+ *
+ * These types form the foundation of the tsb type system.
+ * They are designed to closely mirror pandas' type hierarchy
+ * while being idiomatic TypeScript.
+ */
+
+/** Scalar value types — the atomic units of data in tsb. */
+export type Scalar = number | string | boolean | bigint | null | undefined | Date;
+
+/** A label used to identify rows or columns (similar to pandas Index). */
+export type Label = number | string | boolean | null;
+
+/** Axis identifiers — pandas supports axis=0 (rows) and axis=1 (columns). */
+export type Axis = 0 | 1 | "index" | "columns";
+
+/** Sort order direction. */
+export type SortOrder = "ascending" | "descending";
+
+/** Fill method for missing values. */
+export type FillMethod = "ffill" | "bfill" | "pad" | "backfill";
+
+/** How to handle missing values in joins. */
+export type JoinHow = "inner" | "outer" | "left" | "right";
+
+/** String representation of supported dtypes. */
+export type DtypeName =
+ | "int8"
+ | "int16"
+ | "int32"
+ | "int64"
+ | "uint8"
+ | "uint16"
+ | "uint32"
+ | "uint64"
+ | "float32"
+ | "float64"
+ | "bool"
+ | "string"
+ | "object"
+ | "datetime"
+ | "timedelta"
+ | "category";
diff --git a/tests/core/index.test.ts b/tests/core/index.test.ts
new file mode 100644
index 00000000..962a5f7a
--- /dev/null
+++ b/tests/core/index.test.ts
@@ -0,0 +1,477 @@
+/**
+ * Tests for Index — the generic labeled axis.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { Index } from "../../src/index.ts";
+import type { IndexOptions } from "../../src/index.ts";
+
+// ─── construction ─────────────────────────────────────────────────
+
+describe("Index construction", () => {
+ it("creates an empty Index", () => {
+ const idx = new Index([]);
+ expect(idx.size).toBe(0);
+ expect(idx.empty).toBe(true);
+ });
+
+ it("creates a numeric Index", () => {
+ const idx = new Index([10, 20, 30]);
+ expect(idx.size).toBe(3);
+ expect(idx.toArray()).toEqual([10, 20, 30]);
+ });
+
+ it("creates a string Index", () => {
+ const idx = new Index(["a", "b", "c"]);
+ expect(idx.toArray()).toEqual(["a", "b", "c"]);
+ });
+
+ it("creates an Index with a name", () => {
+ const idx = new Index([1, 2], "myname");
+ expect(idx.name).toBe("myname");
+ });
+
+ it("defaults name to null when omitted", () => {
+ const idx = new Index([1, 2]);
+ expect(idx.name).toBeNull();
+ });
+
+ it("creates via Index.from() factory", () => {
+ const opts: IndexOptions = { data: [1, 2, 3], name: "x" };
+ const idx = Index.from(opts);
+ expect(idx.size).toBe(3);
+ expect(idx.name).toBe("x");
+ });
+
+ it("freezes internal values (immutable)", () => {
+ const data = [1, 2, 3];
+ const idx = new Index(data);
+ data.push(4); // mutating original array doesn't affect Index
+ expect(idx.size).toBe(3);
+ });
+});
+
+// ─── properties ───────────────────────────────────────────────────
+
+describe("Index properties", () => {
+ it("shape returns a 1-element tuple", () => {
+ const idx = new Index([1, 2, 3]);
+ expect(idx.shape).toEqual([3]);
+ });
+
+ it("ndim is always 1", () => {
+ expect(new Index([]).ndim).toBe(1);
+ expect(new Index([1, 2, 3]).ndim).toBe(1);
+ });
+
+ it("empty is true for zero-length", () => {
+ expect(new Index([]).empty).toBe(true);
+ expect(new Index([1]).empty).toBe(false);
+ });
+
+ it("values returns the labels", () => {
+ const idx = new Index(["x", "y"]);
+ expect([...idx.values]).toEqual(["x", "y"]);
+ });
+});
+
+// ─── uniqueness ───────────────────────────────────────────────────
+
+describe("Index uniqueness", () => {
+ it("isUnique is true when all labels are distinct", () => {
+ expect(new Index([1, 2, 3]).isUnique).toBe(true);
+ });
+
+ it("isUnique is false with duplicates", () => {
+ expect(new Index([1, 2, 1]).isUnique).toBe(false);
+ });
+
+ it("hasDuplicates is the inverse of isUnique", () => {
+ expect(new Index([1, 2, 3]).hasDuplicates).toBe(false);
+ expect(new Index([1, 2, 1]).hasDuplicates).toBe(true);
+ });
+});
+
+// ─── monotonicity ─────────────────────────────────────────────────
+
+describe("Index monotonicity", () => {
+ it("detects monotonic increasing", () => {
+ expect(new Index([1, 2, 3]).isMonotonicIncreasing).toBe(true);
+ expect(new Index([1, 1, 2]).isMonotonicIncreasing).toBe(true); // weakly
+ expect(new Index([3, 2, 1]).isMonotonicIncreasing).toBe(false);
+ });
+
+ it("detects monotonic decreasing", () => {
+ expect(new Index([3, 2, 1]).isMonotonicDecreasing).toBe(true);
+ expect(new Index([3, 3, 1]).isMonotonicDecreasing).toBe(true); // weakly
+ expect(new Index([1, 2, 3]).isMonotonicDecreasing).toBe(false);
+ });
+
+ it("empty and single-element are both monotonic", () => {
+ expect(new Index([]).isMonotonicIncreasing).toBe(true);
+ expect(new Index([42]).isMonotonicIncreasing).toBe(true);
+ expect(new Index([]).isMonotonicDecreasing).toBe(true);
+ expect(new Index([42]).isMonotonicDecreasing).toBe(true);
+ });
+
+ it("null values break monotonicity", () => {
+ expect(new Index([1, null, 3]).isMonotonicIncreasing).toBe(false);
+ expect(new Index([3, null, 1]).isMonotonicDecreasing).toBe(false);
+ });
+});
+
+// ─── element access ───────────────────────────────────────────────
+
+describe("Index.at()", () => {
+ it("returns the element at a positive index", () => {
+ const idx = new Index(["a", "b", "c"]);
+ expect(idx.at(0)).toBe("a");
+ expect(idx.at(2)).toBe("c");
+ });
+
+ it("supports negative indexing", () => {
+ const idx = new Index(["a", "b", "c"]);
+ expect(idx.at(-1)).toBe("c");
+ expect(idx.at(-3)).toBe("a");
+ });
+
+ it("throws RangeError for out-of-bounds", () => {
+ const idx = new Index([1, 2]);
+ expect(() => idx.at(5)).toThrow(RangeError);
+ expect(() => idx.at(-3)).toThrow(RangeError);
+ });
+});
+
+describe("Index.slice()", () => {
+ it("returns a sub-index", () => {
+ const idx = new Index([10, 20, 30, 40, 50]);
+ expect(idx.slice(1, 3).toArray()).toEqual([20, 30]);
+ });
+
+ it("preserves name", () => {
+ const idx = new Index([1, 2, 3], "x");
+ expect(idx.slice(0, 2).name).toBe("x");
+ });
+});
+
+describe("Index.take()", () => {
+ it("picks specific positions", () => {
+ const idx = new Index(["a", "b", "c", "d"]);
+ expect(idx.take([3, 1]).toArray()).toEqual(["d", "b"]);
+ });
+});
+
+// ─── look-up ──────────────────────────────────────────────────────
+
+describe("Index.getLoc()", () => {
+ it("returns a single position for unique key", () => {
+ const idx = new Index(["a", "b", "c"]);
+ expect(idx.getLoc("b")).toBe(1);
+ });
+
+ it("returns array for duplicate key", () => {
+ const idx = new Index(["a", "b", "a"]);
+ expect(idx.getLoc("a")).toEqual([0, 2]);
+ });
+
+ it("throws for missing key", () => {
+ const idx = new Index([1, 2, 3]);
+ expect(() => idx.getLoc(99)).toThrow("KeyError");
+ });
+});
+
+describe("Index.getIndexer()", () => {
+ it("maps target labels to positions", () => {
+ const idx = new Index(["a", "b", "c"]);
+ const target = new Index(["c", "a", "z"]);
+ expect(idx.getIndexer(target)).toEqual([2, 0, -1]);
+ });
+});
+
+describe("Index.contains()", () => {
+ it("returns true for present labels", () => {
+ const idx = new Index([1, 2, 3]);
+ expect(idx.contains(2)).toBe(true);
+ });
+
+ it("returns false for absent labels", () => {
+ const idx = new Index([1, 2, 3]);
+ expect(idx.contains(99)).toBe(false);
+ });
+});
+
+describe("Index.isin()", () => {
+ it("returns boolean mask", () => {
+ const idx = new Index([1, 2, 3, 4]);
+ expect(idx.isin([2, 4])).toEqual([false, true, false, true]);
+ });
+});
+
+// ─── set operations ───────────────────────────────────────────────
+
+describe("Index set operations", () => {
+ it("union combines unique labels", () => {
+ const a = new Index([1, 2, 3]);
+ const b = new Index([3, 4, 5]);
+ expect(a.union(b).toArray()).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it("intersection returns common labels", () => {
+ const a = new Index([1, 2, 3]);
+ const b = new Index([2, 3, 4]);
+ expect(a.intersection(b).toArray()).toEqual([2, 3]);
+ });
+
+ it("difference removes other's labels", () => {
+ const a = new Index([1, 2, 3]);
+ const b = new Index([2, 4]);
+ expect(a.difference(b).toArray()).toEqual([1, 3]);
+ });
+
+ it("symmetricDifference returns exclusive labels", () => {
+ const a = new Index([1, 2, 3]);
+ const b = new Index([2, 3, 4]);
+ expect(a.symmetricDifference(b).toArray()).toEqual([1, 4]);
+ });
+
+ it("set operations preserve name from self", () => {
+ const a = new Index([1, 2], "x");
+ const b = new Index([2, 3], "y");
+ expect(a.union(b).name).toBe("x");
+ });
+});
+
+// ─── duplicate handling ───────────────────────────────────────────
+
+describe("Index.duplicated()", () => {
+ const idx = new Index(["a", "b", "a", "c", "b"]);
+
+ it("keep='first' marks later occurrences", () => {
+ expect(idx.duplicated("first")).toEqual([false, false, true, false, true]);
+ });
+
+ it("keep='last' marks earlier occurrences", () => {
+ expect(idx.duplicated("last")).toEqual([true, true, false, false, false]);
+ });
+
+ it("keep=false marks all duplicates", () => {
+ expect(idx.duplicated(false)).toEqual([true, true, true, false, true]);
+ });
+});
+
+describe("Index.dropDuplicates()", () => {
+ it("removes duplicates keeping first", () => {
+ const idx = new Index(["a", "b", "a"]);
+ expect(idx.dropDuplicates("first").toArray()).toEqual(["a", "b"]);
+ });
+
+ it("removes duplicates keeping last", () => {
+ const idx = new Index(["a", "b", "a"]);
+ expect(idx.dropDuplicates("last").toArray()).toEqual(["b", "a"]);
+ });
+});
+
+describe("Index.nunique()", () => {
+ it("counts distinct labels", () => {
+ expect(new Index([1, 2, 2, 3]).nunique()).toBe(3);
+ });
+});
+
+// ─── manipulation ─────────────────────────────────────────────────
+
+describe("Index.append()", () => {
+ it("concatenates two indices", () => {
+ const a = new Index([1, 2]);
+ const b = new Index([3, 4]);
+ expect(a.append(b).toArray()).toEqual([1, 2, 3, 4]);
+ });
+
+ it("concatenates multiple indices", () => {
+ const a = new Index([1]);
+ expect(a.append([new Index([2]), new Index([3])]).toArray()).toEqual([1, 2, 3]);
+ });
+});
+
+describe("Index.insert()", () => {
+ it("inserts at position", () => {
+ const idx = new Index(["a", "c"]);
+ expect(idx.insert(1, "b").toArray()).toEqual(["a", "b", "c"]);
+ });
+});
+
+describe("Index.delete()", () => {
+ it("removes a single position", () => {
+ const idx = new Index([10, 20, 30]);
+ expect(idx.delete(1).toArray()).toEqual([10, 30]);
+ });
+
+ it("removes multiple positions", () => {
+ const idx = new Index([10, 20, 30, 40]);
+ expect(idx.delete([0, 2]).toArray()).toEqual([20, 40]);
+ });
+});
+
+describe("Index.drop()", () => {
+ it("drops by label value", () => {
+ const idx = new Index(["a", "b", "c", "a"]);
+ expect(idx.drop(["a"]).toArray()).toEqual(["b", "c"]);
+ });
+});
+
+describe("Index.copy()", () => {
+ it("produces an equal but independent copy", () => {
+ const idx = new Index([1, 2], "orig");
+ const c = idx.copy();
+ expect(c.equals(idx)).toBe(true);
+ expect(c.name).toBe("orig");
+ });
+
+ it("can change name via copy()", () => {
+ const idx = new Index([1, 2], "orig");
+ expect(idx.copy("new").name).toBe("new");
+ });
+});
+
+describe("Index.rename()", () => {
+ it("returns a new Index with a different name", () => {
+ const idx = new Index([1, 2], "old");
+ const renamed = idx.rename("new");
+ expect(renamed.name).toBe("new");
+ expect(renamed.equals(idx)).toBe(true);
+ });
+});
+
+// ─── comparison ───────────────────────────────────────────────────
+
+describe("Index.equals()", () => {
+ it("is true for identical values", () => {
+ expect(new Index([1, 2]).equals(new Index([1, 2]))).toBe(true);
+ });
+
+ it("is false for different values", () => {
+ expect(new Index([1, 2]).equals(new Index([1, 3]))).toBe(false);
+ });
+
+ it("is false for different lengths", () => {
+ expect(new Index([1, 2]).equals(new Index([1, 2, 3]))).toBe(false);
+ });
+
+ it("ignores name", () => {
+ expect(new Index([1, 2], "a").equals(new Index([1, 2], "b"))).toBe(true);
+ });
+});
+
+describe("Index.identical()", () => {
+ it("requires same values AND name", () => {
+ expect(new Index([1], "x").identical(new Index([1], "x"))).toBe(true);
+ expect(new Index([1], "x").identical(new Index([1], "y"))).toBe(false);
+ });
+});
+
+// ─── conversion ───────────────────────────────────────────────────
+
+describe("Index.toArray() / toList()", () => {
+ it("returns a plain array", () => {
+ const idx = new Index([1, 2, 3]);
+ const arr = idx.toArray();
+ expect(arr).toEqual([1, 2, 3]);
+ expect(idx.toList()).toEqual(arr);
+ });
+});
+
+// ─── aggregation ──────────────────────────────────────────────────
+
+describe("Index aggregation", () => {
+ it("min() returns the smallest", () => {
+ expect(new Index([3, 1, 2]).min()).toBe(1);
+ });
+
+ it("max() returns the largest", () => {
+ expect(new Index([3, 1, 2]).max()).toBe(3);
+ });
+
+ it("min()/max() return undefined for empty Index", () => {
+ expect(new Index([]).min()).toBeUndefined();
+ expect(new Index([]).max()).toBeUndefined();
+ });
+
+ it("argmin() returns position of minimum", () => {
+ expect(new Index([30, 10, 20]).argmin()).toBe(1);
+ });
+
+ it("argmax() returns position of maximum", () => {
+ expect(new Index([30, 10, 20]).argmax()).toBe(0);
+ });
+
+ it("argmin()/argmax() throw on empty Index", () => {
+ expect(() => new Index([]).argmin()).toThrow();
+ expect(() => new Index([]).argmax()).toThrow();
+ });
+});
+
+describe("Index.argsort()", () => {
+ it("returns the sort permutation", () => {
+ const idx = new Index([30, 10, 20]);
+ expect(idx.argsort()).toEqual([1, 2, 0]);
+ });
+});
+
+describe("Index.sortValues()", () => {
+ it("sorts ascending by default", () => {
+ expect(new Index([3, 1, 2]).sortValues().toArray()).toEqual([1, 2, 3]);
+ });
+
+ it("sorts descending when requested", () => {
+ expect(new Index([3, 1, 2]).sortValues(false).toArray()).toEqual([3, 2, 1]);
+ });
+});
+
+// ─── missing-value helpers ────────────────────────────────────────
+
+describe("Index missing-value helpers", () => {
+ it("isna() marks nulls", () => {
+ expect(new Index([1, null, 3]).isna()).toEqual([false, true, false]);
+ });
+
+ it("notna() is the inverse of isna()", () => {
+ expect(new Index([1, null, 3]).notna()).toEqual([true, false, true]);
+ });
+
+ it("dropna() removes nulls", () => {
+ expect(new Index([1, null, 3]).dropna().toArray()).toEqual([1, 3]);
+ });
+
+ it("fillna() replaces nulls", () => {
+ expect(new Index([1, null, 3]).fillna(0).toArray()).toEqual([1, 0, 3]);
+ });
+});
+
+// ─── iteration / misc ────────────────────────────────────────────
+
+describe("Index iteration", () => {
+ it("supports for…of", () => {
+ const idx = new Index([10, 20, 30]);
+ const collected: number[] = [];
+ for (const v of idx) {
+ collected.push(v);
+ }
+ expect(collected).toEqual([10, 20, 30]);
+ });
+
+ it("toString() produces a readable representation", () => {
+ const idx = new Index([1, 2], "x");
+ expect(idx.toString()).toBe("Index([1, 2], name='x')");
+ });
+
+ it("toString() omits name when null", () => {
+ expect(new Index([1]).toString()).toBe("Index([1])");
+ });
+});
+
+describe("Index.map()", () => {
+ it("transforms each label", () => {
+ const idx = new Index([1, 2, 3]);
+ const doubled = idx.map((v) => (v !== null ? v * 2 : null));
+ expect(doubled.toArray()).toEqual([2, 4, 6]);
+ });
+});
diff --git a/tests/core/range-index.test.ts b/tests/core/range-index.test.ts
new file mode 100644
index 00000000..4909dfd6
--- /dev/null
+++ b/tests/core/range-index.test.ts
@@ -0,0 +1,242 @@
+/**
+ * Tests for RangeIndex — memory-efficient integer index.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { Index, RangeIndex } from "../../src/index.ts";
+
+// ─── construction ─────────────────────────────────────────────────
+
+describe("RangeIndex construction", () => {
+ it("RangeIndex(5) produces [0,1,2,3,4]", () => {
+ const r = new RangeIndex(5);
+ expect(r.toArray()).toEqual([0, 1, 2, 3, 4]);
+ expect(r.start).toBe(0);
+ expect(r.stop).toBe(5);
+ expect(r.step).toBe(1);
+ });
+
+ it("RangeIndex(2, 6) produces [2,3,4,5]", () => {
+ const r = new RangeIndex(2, 6);
+ expect(r.toArray()).toEqual([2, 3, 4, 5]);
+ });
+
+ it("RangeIndex(0, 10, 3) produces [0,3,6,9]", () => {
+ const r = new RangeIndex(0, 10, 3);
+ expect(r.toArray()).toEqual([0, 3, 6, 9]);
+ });
+
+ it("negative step produces descending range", () => {
+ const r = new RangeIndex(5, 0, -1);
+ expect(r.toArray()).toEqual([5, 4, 3, 2, 1]);
+ });
+
+ it("empty range when start >= stop (positive step)", () => {
+ const r = new RangeIndex(5, 5);
+ expect(r.size).toBe(0);
+ expect(r.empty).toBe(true);
+ });
+
+ it("empty range when start <= stop (negative step)", () => {
+ const r = new RangeIndex(0, 5, -1);
+ expect(r.size).toBe(0);
+ });
+
+ it("throws on step=0", () => {
+ expect(() => new RangeIndex(0, 5, 0)).toThrow(RangeError);
+ });
+
+ it("accepts a name", () => {
+ const r = new RangeIndex(3, undefined, undefined, "idx");
+ expect(r.name).toBe("idx");
+ });
+});
+
+// ─── properties ───────────────────────────────────────────────────
+
+describe("RangeIndex properties", () => {
+ it("size is correct", () => {
+ expect(new RangeIndex(5).size).toBe(5);
+ expect(new RangeIndex(0, 10, 2).size).toBe(5);
+ });
+
+ it("shape returns [size]", () => {
+ expect(new RangeIndex(5).shape).toEqual([5]);
+ });
+
+ it("is always unique", () => {
+ expect(new RangeIndex(5).isUnique).toBe(true);
+ });
+
+ it("never has duplicates", () => {
+ expect(new RangeIndex(5).hasDuplicates).toBe(false);
+ });
+});
+
+// ─── monotonicity ─────────────────────────────────────────────────
+
+describe("RangeIndex monotonicity", () => {
+ it("positive step is monotonic increasing", () => {
+ const r = new RangeIndex(5);
+ expect(r.isMonotonicIncreasing).toBe(true);
+ expect(r.isMonotonicDecreasing).toBe(false);
+ });
+
+ it("negative step is monotonic decreasing", () => {
+ const r = new RangeIndex(5, 0, -1);
+ expect(r.isMonotonicDecreasing).toBe(true);
+ expect(r.isMonotonicIncreasing).toBe(false);
+ });
+
+ it("single-element is both monotonic", () => {
+ const r = new RangeIndex(1);
+ expect(r.isMonotonicIncreasing).toBe(true);
+ expect(r.isMonotonicDecreasing).toBe(true);
+ });
+
+ it("empty is both monotonic", () => {
+ const r = new RangeIndex(0);
+ expect(r.isMonotonicIncreasing).toBe(true);
+ expect(r.isMonotonicDecreasing).toBe(true);
+ });
+});
+
+// ─── element access ───────────────────────────────────────────────
+
+describe("RangeIndex element access", () => {
+ it("at() retrieves correct elements", () => {
+ const r = new RangeIndex(0, 10, 2);
+ expect(r.at(0)).toBe(0);
+ expect(r.at(2)).toBe(4);
+ expect(r.at(-1)).toBe(8);
+ });
+
+ it("slice() returns a RangeIndex", () => {
+ const r = new RangeIndex(10);
+ const sliced = r.slice(2, 5);
+ expect(sliced).toBeInstanceOf(RangeIndex);
+ expect(sliced.toArray()).toEqual([2, 3, 4]);
+ });
+
+ it("slice() on stepped range", () => {
+ const r = new RangeIndex(0, 20, 5); // [0, 5, 10, 15]
+ const sliced = r.slice(1, 3);
+ expect(sliced.toArray()).toEqual([5, 10]);
+ });
+
+ it("empty slice returns empty RangeIndex", () => {
+ const r = new RangeIndex(5);
+ const sliced = r.slice(3, 3);
+ expect(sliced.size).toBe(0);
+ expect(sliced).toBeInstanceOf(RangeIndex);
+ });
+});
+
+// ─── inherited Index methods ──────────────────────────────────────
+
+describe("RangeIndex inherits Index methods", () => {
+ it("getLoc() finds positions", () => {
+ const r = new RangeIndex(0, 10, 2); // [0, 2, 4, 6, 8]
+ expect(r.getLoc(4)).toBe(2);
+ });
+
+ it("contains() works", () => {
+ const r = new RangeIndex(5);
+ expect(r.contains(3)).toBe(true);
+ expect(r.contains(5)).toBe(false);
+ });
+
+ it("isin() returns boolean mask", () => {
+ const r = new RangeIndex(5); // [0,1,2,3,4]
+ expect(r.isin([1, 3])).toEqual([false, true, false, true, false]);
+ });
+
+ it("union() combines ranges", () => {
+ const a = new RangeIndex(3); // [0,1,2]
+ const b = new Index([2, 3, 4]);
+ expect(a.union(b).toArray()).toEqual([0, 1, 2, 3, 4]);
+ });
+
+ it("intersection() finds overlap", () => {
+ const a = new RangeIndex(5); // [0,1,2,3,4]
+ const b = new Index([3, 4, 5, 6]);
+ expect(a.intersection(b).toArray()).toEqual([3, 4]);
+ });
+
+ it("difference() removes other's values", () => {
+ const a = new RangeIndex(5); // [0,1,2,3,4]
+ const b = new Index([1, 3]);
+ expect(a.difference(b).toArray()).toEqual([0, 2, 4]);
+ });
+
+ it("sortValues() returns sorted index", () => {
+ const r = new RangeIndex(5, 0, -1); // [5,4,3,2,1]
+ expect(r.sortValues().toArray()).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it("argsort() returns sort permutation", () => {
+ const r = new RangeIndex(3, 0, -1); // [3,2,1]
+ expect(r.argsort()).toEqual([2, 1, 0]);
+ });
+
+ it("min() and max()", () => {
+ const r = new RangeIndex(0, 10, 2); // [0,2,4,6,8]
+ expect(r.min()).toBe(0);
+ expect(r.max()).toBe(8);
+ });
+});
+
+// ─── is a subtype of Index ────────────────────────────────────────
+
+describe("RangeIndex is an Index", () => {
+ it("instanceof Index is true", () => {
+ const r = new RangeIndex(5);
+ expect(r).toBeInstanceOf(Index);
+ });
+
+ it("can be assigned to Index variable", () => {
+ const idx: Index = new RangeIndex(5);
+ expect(idx.size).toBe(5);
+ });
+});
+
+// ─── copy / rename ────────────────────────────────────────────────
+
+describe("RangeIndex copy/rename", () => {
+ it("copy() returns a RangeIndex", () => {
+ const r = new RangeIndex(0, 10, 2, "orig");
+ const c = r.copy();
+ expect(c).toBeInstanceOf(RangeIndex);
+ expect(c.start).toBe(0);
+ expect(c.stop).toBe(10);
+ expect(c.step).toBe(2);
+ expect(c.name).toBe("orig");
+ });
+
+ it("copy(newName) changes name", () => {
+ const r = new RangeIndex(3, undefined, undefined, "old");
+ expect(r.copy("new").name).toBe("new");
+ });
+
+ it("rename() returns a RangeIndex with new name", () => {
+ const r = new RangeIndex(3);
+ const renamed = r.rename("x");
+ expect(renamed).toBeInstanceOf(RangeIndex);
+ expect(renamed.name).toBe("x");
+ expect(renamed.toArray()).toEqual(r.toArray());
+ });
+});
+
+// ─── toString ─────────────────────────────────────────────────────
+
+describe("RangeIndex.toString()", () => {
+ it("includes start, stop, step", () => {
+ const r = new RangeIndex(0, 10, 2);
+ expect(r.toString()).toBe("RangeIndex(start=0, stop=10, step=2)");
+ });
+
+ it("includes name when set", () => {
+ const r = new RangeIndex(5, undefined, undefined, "myidx");
+ expect(r.toString()).toContain("name='myidx'");
+ });
+});
diff --git a/tests/index.test.ts b/tests/index.test.ts
new file mode 100644
index 00000000..d850fba0
--- /dev/null
+++ b/tests/index.test.ts
@@ -0,0 +1,96 @@
+/**
+ * Hello-world test — proves the tsb package entry point loads correctly.
+ *
+ * This is the minimal end-to-end smoke test for the project foundation.
+ * It verifies that:
+ * 1. The package can be imported.
+ * 2. The version constant is exported and has the expected shape.
+ * 3. The core type system compiles without errors.
+ */
+
+import { describe, expect, it } from "bun:test";
+import { TSB_VERSION } from "../src/index.ts";
+import type {
+ Axis,
+ DtypeName,
+ FillMethod,
+ JoinHow,
+ Label,
+ Scalar,
+ SortOrder,
+} from "../src/index.ts";
+
+const SEMVER_RE = /^\d+\.\d+\.\d+$/;
+
+describe("tsb package foundation", () => {
+ it("exports TSB_VERSION as a semver-shaped string", () => {
+ expect(TSB_VERSION).toBeTypeOf("string");
+ expect(TSB_VERSION).toMatch(SEMVER_RE);
+ });
+
+ it("TSB_VERSION is 0.0.1 at project inception", () => {
+ expect(TSB_VERSION).toBe("0.0.1");
+ });
+});
+
+describe("type exports compile correctly", () => {
+ it("Scalar type accepts all expected primitive kinds", () => {
+ // Type-level tests: these assignments would fail to compile if types were wrong.
+ const _num: Scalar = 42;
+ const _str: Scalar = "hello";
+ const _bool: Scalar = true;
+ const _big: Scalar = BigInt("9007199254740993");
+ const _null: Scalar = null;
+ const _undef: Scalar = undefined;
+ const _date: Scalar = new Date();
+ expect(true).toBe(true); // all assignments above compiled → test passes
+ });
+
+ it("Label type accepts number, string, boolean, null", () => {
+ const _n: Label = 0;
+ const _s: Label = "key";
+ const _b: Label = false;
+ const _nil: Label = null;
+ expect(true).toBe(true);
+ });
+
+ it("Axis type accepts numeric and named variants", () => {
+ const _a0: Axis = 0;
+ const _a1: Axis = 1;
+ const _aIdx: Axis = "index";
+ const _aCol: Axis = "columns";
+ expect(true).toBe(true);
+ });
+
+ it("SortOrder, FillMethod, JoinHow are valid discriminated unions", () => {
+ const _asc: SortOrder = "ascending";
+ const _desc: SortOrder = "descending";
+ const _ffill: FillMethod = "ffill";
+ const _bfill: FillMethod = "bfill";
+ const _inner: JoinHow = "inner";
+ const _outer: JoinHow = "outer";
+ expect(true).toBe(true);
+ });
+
+ it("DtypeName covers all planned numeric and structured types", () => {
+ const dtypes: DtypeName[] = [
+ "int8",
+ "int16",
+ "int32",
+ "int64",
+ "uint8",
+ "uint16",
+ "uint32",
+ "uint64",
+ "float32",
+ "float64",
+ "bool",
+ "string",
+ "object",
+ "datetime",
+ "timedelta",
+ "category",
+ ];
+ expect(dtypes.length).toBe(16);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 00000000..79900ebc
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,6 @@
+/**
+ * Test setup — loaded before all tests via bunfig.toml [test].preload.
+ */
+
+// Nothing to set up yet. This file exists to support future global setup
+// such as custom matchers, test fixtures, and environment configuration.
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..3cac4a5a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ESNext", "DOM"],
+ "outDir": "./dist",
+ "rootDir": ".",
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "noImplicitAny": true,
+ "noImplicitReturns": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "useUnknownInCatchVariables": true,
+ "forceConsistentCasingInFileNames": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": false,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "skipLibCheck": false,
+ "types": ["bun-types"]
+ },
+ "include": ["src/**/*", "tests/**/*"],
+ "exclude": ["node_modules", "dist", "playground"]
+}