# Type Inference & Data Profiling

How Astrolabe looks at a tabular dataset and figures out, for each column, what
kind of data it holds — `number`, `string`, `date`, or `boolean` — and how it
rolls those facts up into the **profile** stored on a dataset record.

This is pure, portable logic. It lives in `src/core/`, touches no browser APIs
and no React, takes plain values in and returns plain data out, and is covered
by Vitest unit tests. Anything that needs a profile (the create form, the edit
flow, the detail panel) calls into this module; nothing here reaches back out.

---

## 1. Why infer types at all

A dataset is just rows of values. The UI wants to _describe_ it without
re-parsing the payload every time: how many rows and columns, what the columns
are called, and roughly what each column contains. The inferred type drives the
small type indicator next to each column name in the dataset detail panel and
the meta line in the list. It is a **display hint**, not a contract — nothing
downstream coerces values based on it, and Vega-Lite does its own type handling
at render time. Because it is only a hint, a wrong guess is cheap, and the rules
below favour being simple and predictable over being clever.

We support exactly **four** inferred types:

| Type      | Meaning                                              |
| --------- | ---------------------------------------------------- |
| `number`  | Every non-empty value is numeric.                    |
| `boolean` | Every non-empty value is `true`/`false` (any case).  |
| `date`    | Every non-empty value parses as a date.              |
| `string`  | The fallback — anything that isn't one of the above. |

There is deliberately no integer/float split, no datetime-vs-date distinction,
and no JSON type. Those distinctions add branches and edge cases without
changing what the user sees. Keep it at four.

---

## 2. Inferring one column

Given the values of a single column, decide its type.

### The shape of the algorithm

1. **Drop the empties.** Filter out `null`, `undefined`, and empty/whitespace-only
   strings before doing anything. Empty cells carry no type signal — a column of
   numbers with a few blanks is still a number column.
2. **All-empty → `string`.** If nothing survives the filter (the column is
   entirely empty, or there are zero rows), default to `string`. There is no
   evidence for any other type.
3. **Run the type checks in precedence order.** For each candidate type, ask:
   _does **every** surviving value match this type?_ The first candidate for
   which the answer is yes wins. This is the **"all values match → that type,
   else fall back"** rule: one stray value that doesn't fit knocks the column
   down to the next candidate, and ultimately to `string`.

### Precedence order matters

The order of the checks is not arbitrary — it exists because the value-sets
overlap, and we want the most specific interpretation that fits.

1. **boolean** first. The strings `"true"`/`"false"` are not numbers and not
   dates, so booleans never collide with the other checks — but putting them
   first keeps a `0`/`1`-free true/false column out of `string`. (We do _not_
   treat `0`/`1` as boolean; that's a number column.)
2. **number** second. `Number("2024")` is a perfectly good number, so a column
   of bare years would read as `number` — which is the honest answer. Numbers
   are checked before dates so that plain numeric columns never get
   mis-classified as dates by an over-eager date parser.
3. **date** third. Date parsing is the loosest, most permissive check, so it
   goes last among the positive checks. By the time we reach it we already know
   the column isn't all-boolean and isn't all-numeric.
4. **string** is the fallback when no positive check matches every value.

> Mnemonic: **boolean → number → date → string**, narrowest evidence to widest.

### What counts as each type

- **numeric**: trim the string form; reject empty; `Number(trimmed)` must be
  finite and not `NaN`. (Native `number` values pass directly.) Reject blank and
  whitespace so `Number("") === 0` doesn't sneak through.
- **boolean**: native `boolean` values pass; otherwise the trimmed,
  lower-cased string must be exactly `"true"` or `"false"`.
- **date**: guard _before_ parsing. Require the trimmed value to look
  date-shaped (a leading `YYYY-MM-DD` or `YYYY/MM/DD`, or `M/D/YYYY`) **and**
  then confirm `Date.parse` returns a finite timestamp. The shape guard is
  essential: `Date.parse` will happily accept `"42"` or `"March"` on some
  engines, which would swallow number and string columns. Never rely on
  `Date.parse` alone.

### Sketch

```ts
// src/core/type-inference.ts
export type ColumnType = 'number' | 'string' | 'date' | 'boolean';

const isEmpty = (v: unknown): boolean =>
  v === null || v === undefined || (typeof v === 'string' && v.trim() === '');

const isNumeric = (v: unknown): boolean => {
  if (typeof v === 'number') return Number.isFinite(v);
  if (typeof v !== 'string') return false;
  const t = v.trim();
  if (t === '') return false;
  const n = Number(t);
  return !Number.isNaN(n) && Number.isFinite(n);
};

const isBoolean = (v: unknown): boolean => {
  if (typeof v === 'boolean') return true;
  if (typeof v !== 'string') return false;
  const t = v.trim().toLowerCase();
  return t === 'true' || t === 'false';
};

// Shape guard first, then confirm it actually parses.
const DATE_SHAPE = /^\d{4}[-/]\d{2}[-/]\d{2}|^\d{1,2}\/\d{1,2}\/\d{4}/;
const isDate = (v: unknown): boolean => {
  if (typeof v !== 'string') return false;
  const t = v.trim();
  return DATE_SHAPE.test(t) && !Number.isNaN(Date.parse(t));
};

/**
 * Infer one of four column types from a sample of column values.
 * Empty cells are ignored; an all-empty column is `string`.
 * Precedence: boolean → number → date → string.
 */
export function inferColumnType(values: readonly unknown[]): ColumnType {
  const present = values.filter((v) => !isEmpty(v));
  if (present.length === 0) return 'string';

  if (present.every(isBoolean)) return 'boolean';
  if (present.every(isNumeric)) return 'number';
  if (present.every(isDate)) return 'date';
  return 'string';
}
```

### Robustness notes

- **Mixed columns** fall through to `string` automatically — the `every` check
  fails on the first non-conforming value, so a column of mostly-numbers with
  one label is `string`, which is the safe, honest answer.
- **Whitespace** is trimmed in every check, so `" 42 "` reads as numeric and
  `"  "` is treated as empty.
- **Empty columns** (all cells blank, or a zero-row dataset) return `string` by
  the all-empty rule — never throw, never guess.
- **Large columns**: see §4. `inferColumnType` itself just consumes whatever
  array it's handed; the caller decides whether to sample.

### Do / Don't

- **Do** ignore empty cells before classifying.
- **Do** keep the precedence boolean → number → date → string.
- **Do** guard date detection with a shape regex before trusting `Date.parse`.
- **Don't** classify a column unless _every_ present value matches — one
  outlier means `string`.
- **Don't** add more types (integer, float, datetime, json). Four, no more.
- **Don't** let `Number("")`, `Date.parse("42")`, or `0`/`1` leak into the wrong
  bucket.

---

## 3. Profiling a dataset

A **profile** is the set of derived summary fields stored on a dataset record so
the UI can describe it without re-parsing the payload. Per the data model, a
profiled dataset carries:

| Field         | Type                                                       | Meaning                                                            |
| ------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
| `rowCount`    | `number \| null`                                           | Data rows, or `null` when N/A.                                     |
| `columnCount` | `number \| null`                                           | Columns, or `null` when N/A.                                       |
| `columns`     | `string[]`                                                 | Column names, in order.                                            |
| `columnTypes` | `Array<{ name; type }>`                                    | Per-column inferred type (see §2).                                 |
| `columnStats` | `Array<{ name; distinct; distinctCapped; numericExtent }>` | Per-column cardinality + numeric range (see §3.3). Empty when N/A. |
| `size`        | `number`                                                   | Approximate payload size in bytes.                                 |

`null` row/column counts and an empty `columns`/`columnTypes`/`columnStats` are
how the UI shows **"N/A"** — see §3.2.

### 3.1 What gets profiled

Profiling applies to any **tabular payload**, whether pasted inline or fetched
from a URL (the snapshot model stores a URL dataset's data locally, so it profiles
through the same path as inline data — `snapshotFromText` shapes the fetched body,
then `computeDatasetProfile` runs):

- **JSON** that is an array of objects.
- **CSV** (comma-separated, header row).
- **TSV** (tab-separated, header row).

Everything else is **not profiled**:

- **Unfetched URL datasets** — a URL reference with no snapshot yet (e.g. one
  migrated from an older record), so there is nothing to scan. Counts are `null` /
  N/A until it is refreshed.
- **Non-tabular data** — a single JSON object, TopoJSON, or anything we can't
  read as rows-of-columns. Counts are `null` / N/A.

For the not-profiled cases, `size` is still computed (it's just the byte length
of the stored payload), but `rowCount` and `columnCount` are `null`, and
`columns`/`columnTypes` are empty.

### 3.2 The algorithm

1. **Compute `size`** from the raw payload regardless of whether it's tabular —
   byte length of the text (CSV/TSV) or of the JSON-serialized value.
2. **Decide if it's tabular.** Map `(format, parsed shape)` to a row set:
   - `csv` / `tsv` → parse into rows-of-objects using the matching delimiter.
   - `json` that is a non-empty **array of objects** → use it directly.
   - anything else (`topojson`, a lone JSON object, an empty array) → not
     tabular; return the N/A profile (`rowCount: null`, `columnCount: null`,
     `columns: []`, `columnTypes: []`, `columnStats: []`, plus `size`).
3. **Derive columns** from the union of keys across the rows (or the CSV/TSV
   header), preserving first-seen order.
4. **Infer each column's type and stats** by collecting that column's values across
   the rows (sampling per §4) and calling `inferColumnType` (§2) plus deriving its
   cardinality + numeric extent (§3.3) — one pass per column over the same sample.
5. **Assemble** `rowCount`, `columnCount`, `columns`, `columnTypes`, `columnStats`,
   `size`.

### Sketch

```ts
// src/core/profile.ts
import { inferColumnType, type ColumnType } from './type-inference';

export interface DatasetProfile {
  rowCount: number | null;
  columnCount: number | null;
  columns: string[];
  columnTypes: Array<{ name: string; type: ColumnType }>;
  columnStats: Array<{
    name: string;
    distinct: number; // capped at DISTINCT_CAP
    distinctCapped: boolean; // true ⇒ real cardinality ≥ DISTINCT_CAP
    numericExtent: { min: number; max: number } | null; // numeric columns only
  }>;
  size: number;
}

const NA = (size: number): DatasetProfile => ({
  rowCount: null,
  columnCount: null,
  columns: [],
  columnTypes: [],
  columnStats: [],
  size,
});

/** Profile a dataset payload. `rows` is the tabular form (CSV/TSV/JSON-array)
 *  already parsed to rows-of-objects, or null for non-tabular / URL data. */
export function profileData(
  rows: ReadonlyArray<Record<string, unknown>> | null,
  size: number,
): DatasetProfile {
  if (!rows || rows.length === 0) return NA(size);

  // Column order = first-seen order across all rows.
  const columns: string[] = [];
  const seen = new Set<string>();
  for (const row of rows) {
    for (const key of Object.keys(row)) {
      if (!seen.has(key)) {
        seen.add(key);
        columns.push(key);
      }
    }
  }
  if (columns.length === 0) return NA(size);

  const sample = sampleRows(rows);
  // One pass per column over the sample: type + stats (cardinality, numeric extent).
  const columnTypes = [];
  const columnStats = [];
  for (const name of columns) {
    const values = sample.map((r) => r[name]);
    const type = inferColumnType(values);
    columnTypes.push({ name, type });
    columnStats.push(columnStatsFor(name, values, type)); // see §3.3
  }

  return {
    rowCount: rows.length,
    columnCount: columns.length,
    columns,
    columnTypes,
    columnStats,
    size,
  };
}
```

Parsing CSV/TSV text and detecting the payload shape happen _upstream_ of
`profileData`; this function takes already-parsed rows so it stays pure and
trivially testable. The caller passes `null` for URL and non-tabular datasets.

### 3.3 Column stats: cardinality + numeric extent

Alongside the display type, each column carries the two data-shape signals the
**Chart Builder** needs for its data-aware Tier-B hints (`chart-builder.ts`
`builderWarnings`) **and** for its default pre-population (`smartDefaultEncodings`
prefers a low-cardinality category over a high-cardinality key, so the builder never
opens on a degenerate chart; spec §06):

- **`distinct`** — the count of distinct non-empty values **in the sample**,
  counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
  mattering — a legend or category axis with 50 entries is already unreadable — so
  counting stops and **`distinctCapped`** flags that the real cardinality is at
  least the cap. Powers the crowded-legend (discrete colour) and crowded-category
  warnings.
- **`numericExtent`** — `{ min, max }` over the numeric values, for `number`
  columns only (`null` otherwise), using the **same numeric test** as
  `inferColumnType` so the two never disagree. Powers the negative-value **Size**
  guard (size encodes magnitude → negatives mislead; Draco `hard.lp:56`).

Both are derived in the same sample pass as type inference, so they are nearly
free, and both are **estimates** (sampled from the head, like the type) — fine for
soft advisories, not guarantees. URL / non-tabular data and datasets stored before
this field carry empty `columnStats`, and the dependent hints simply skip.

---

## 4. Sampling vs. full scan

`rowCount`/`columnCount`/`size` always reflect the **whole** dataset — they're
cheap (a length and a byte count). Only the **per-value** work — type inference
and the §3.3 column stats — has a per-row cost, and it's the one place a huge
dataset could hurt.

So: derive types **and** stats from a **bounded sample** of rows, not the full
column. A fixed cap (e.g. the first ~200 rows) keeps profiling fast and
predictable on large pasted datasets while still being more than enough signal to
classify a column and estimate its cardinality / range.

```ts
const SAMPLE_SIZE = 200;
const sampleRows = <T>(rows: ReadonlyArray<T>): ReadonlyArray<T> =>
  rows.length <= SAMPLE_SIZE ? rows : rows.slice(0, SAMPLE_SIZE);
```

Trade-off to be aware of: a column that is numeric for its first 200 rows but
turns to text later will be mis-typed as `number`. That's an accepted cost — the
type is a display hint, the mistake is cheap, and the speed win on large
datasets is worth it. Sampling the head (rather than randomly) keeps results
**deterministic**, which matters for tests and for not surprising the user when
the same paste profiles the same way twice.

### Do / Don't

- **Do** count rows/columns and size over the full payload.
- **Do** cap type-inference **and column-stats** sampling at a fixed head slice for
  determinism (so `distinct` / `numericExtent` are estimates, like the type).
- **Don't** randomly sample — non-deterministic profiles break tests and confuse
  users.
- **Don't** scan every value of a million-row paste to guess a type.

---

## 5. Testing

Both functions are pure, so tests are plain input/output assertions in Vitest —
no mocks, no DOM, no fixtures beyond literal arrays.

Cover at least:

- **`inferColumnType`**: each type detected from a clean column; mixed columns
  fall to `string`; empty/whitespace cells ignored; all-empty and zero-length →
  `string`; precedence (a `["true","false"]` column is `boolean` not `string`; a
  `["2024","2025"]` column is `number` not `date`); date shape guard rejects
  `"42"` and `"hello"` even though one engine's `Date.parse` might accept them;
  `0`/`1` are `number`, not `boolean`.
- **`profileData`**: a JSON-array dataset profiles fully; `null` rows (URL) and
  an empty array (non-tabular) return the N/A profile but still carry `size`;
  column order follows first-seen key order across ragged rows; sampling cap is
  respected (a dataset longer than the cap still profiles, using only the head).

---

## Summary

- Four types only: **boolean → number → date → string**, checked in that order.
- **All present values must match** a type or the column falls through; empty
  cells are ignored; an all-empty column is `string`.
- Guard date detection with a shape regex before trusting `Date.parse`.
- A **profile** carries `rowCount`, `columnCount`, `columns`, `columnTypes`,
  `size`; URL and non-tabular datasets get a **null/N-A** profile (still sized).
- Counts and size scan the whole payload; **type inference samples the head** for
  speed and determinism.
- All of it is **pure `src/core/` logic, unit-tested with Vitest**.
