/** * Dataset profiling (spec §05 → Profiling; docs/architecture/06 §3–§4). * * Portable core: no browser APIs, no React. 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: row/column counts, column names (in order), a per-column * inferred type, and an approximate byte size. * * `profileData` takes already-parsed rows-of-objects (the tabular form of a * CSV/TSV/JSON-array payload) plus a precomputed `size`. Parsing the delimited * text and deciding the payload shape happen *upstream* (see dataset.ts) so this * function stays pure and trivially testable. * * - `null` rows (URL data) or an empty array (non-tabular) → the **N/A profile** * (`rowCount`/`columnCount` null, empty columns), but `size` is still carried. * - Columns are the union of keys across all rows, in **first-seen order**. * - `rowCount`/`columnCount`/`size` reflect the **whole** payload; only type * inference samples — capped at the first `SAMPLE_SIZE` rows for speed and * determinism (arch 06 §4). */ import { inferColumnType, isEmpty, isNumeric, type ColumnType } from './type-inference'; export interface ColumnTypeInfo { /** The column name. */ name: string; /** The inferred display type for the column. */ type: ColumnType; } /** * Cap on distinct values counted per column. Past this the exact count stops * mattering for the chart-guidance hints (a legend or category axis with 50 * entries is already unreadable), so counting stops and `distinctCapped` flags * that the true cardinality is at least this. Sampled like type inference (head of * the data), so it is an estimate — fine for soft advisories, not a guarantee. */ export const DISTINCT_CAP = 50; /** * Per-column cardinality + numeric range, derived in the same sample pass as type * inference (arch 06 §4) to power the Chart Builder's data-aware Tier-B hints: * a crowded colour legend / category axis (`distinct`), and the negative-value * Size guard (`numericExtent`). Distinct from `ColumnTypeInfo` because it answers * "what shape is this data" rather than "what type to encode it as". */ export interface ColumnStats { /** Column name (matches a `columns` entry). */ name: string; /** Distinct non-empty values in the sample, capped at `DISTINCT_CAP`. */ distinct: number; /** True when `distinct` hit the cap — the real cardinality is ≥ `DISTINCT_CAP`. */ distinctCapped: boolean; /** Min/max over numeric values, for numeric columns only; `null` otherwise. */ numericExtent: { min: number; max: number } | null; } export interface DatasetProfile { /** Data rows, or `null` when N/A (URL / non-tabular). */ rowCount: number | null; /** Columns, or `null` when N/A. */ columnCount: number | null; /** Column names, in first-seen order. */ columns: string[]; /** Per-column inferred type. */ columnTypes: ColumnTypeInfo[]; /** Per-column cardinality + numeric range (empty when N/A). */ columnStats: ColumnStats[]; /** Approximate payload size in bytes. */ size: number; } /** The N/A profile for URL / non-tabular data — still carries the byte size. */ const naProfile = (size: number): DatasetProfile => ({ rowCount: null, columnCount: null, columns: [], columnTypes: [], columnStats: [], size, }); /** * Cap on rows fed to type inference. Counts and size scan the whole payload; only * the per-value type check is bounded, sampling the head for determinism (§4). */ const SAMPLE_SIZE = 200; const sampleRows = (rows: ReadonlyArray): ReadonlyArray => rows.length <= SAMPLE_SIZE ? rows : rows.slice(0, SAMPLE_SIZE); /** A canonical key for distinct-counting (trim strings; stringify the rest). */ const distinctKey = (v: unknown): string => (typeof v === 'string' ? v.trim() : String(v)); /** * Cardinality + numeric extent for one column's sampled values. Distinct counting * stops growing the set once it passes `DISTINCT_CAP` (the extra value reveals the * overflow), but the extent scan continues over every value. Extent is reported * only for `number` columns — the only ones the Size channel can encode — using * the same numeric test as type inference, so the two never disagree. */ function columnStatsFor(name: string, values: readonly unknown[], type: ColumnType): ColumnStats { const seen = new Set(); let capped = false; let min = Infinity; let max = -Infinity; let sawNumber = false; for (const v of values) { if (isEmpty(v)) continue; if (!capped) { seen.add(distinctKey(v)); if (seen.size > DISTINCT_CAP) capped = true; } if (type === 'number' && isNumeric(v)) { sawNumber = true; const n = typeof v === 'number' ? v : Number(String(v).trim()); if (n < min) min = n; if (n > max) max = n; } } return { name, distinct: capped ? DISTINCT_CAP : seen.size, distinctCapped: capped, numericExtent: sawNumber ? { min, max } : null, }; } /** * Profile a dataset payload. `rows` is the tabular form (CSV/TSV/JSON-array) * already parsed to rows-of-objects, or `null` for URL / non-tabular data; * `size` is the precomputed byte length of the stored payload. */ export function profileData( rows: ReadonlyArray> | null, size: number, ): DatasetProfile { if (!rows || rows.length === 0) return naProfile(size); // Column order = first-seen order across all rows (handles ragged rows). const columns: string[] = []; const seen = new Set(); 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 naProfile(size); const sample = sampleRows(rows); // One pass per column over the sample: the inferred display type, plus the // cardinality + numeric extent that power the Chart Builder's data-aware hints // (crowded legend / category axis, negative-value Size guard — see chart-builder // `builderWarnings`; docs/chart-builder-research.md §8). const columnTypes: ColumnTypeInfo[] = []; const columnStats: 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)); } return { rowCount: rows.length, columnCount: columns.length, columns, columnTypes, columnStats, size, }; }