Profile per-column cardinality and extent; add data-aware chart warnings (M4, A3/A4)

This commit is contained in:
2026-06-07 23:03:17 +03:00
parent dcb7037a71
commit 39555322e4
13 changed files with 552 additions and 90 deletions
+83 -9
View File
@@ -19,7 +19,7 @@
* determinism (arch 06 §4).
*/
import { inferColumnType, type ColumnType } from './type-inference';
import { inferColumnType, isEmpty, isNumeric, type ColumnType } from './type-inference';
export interface ColumnTypeInfo {
/** The column name. */
@@ -28,6 +28,33 @@ export interface ColumnTypeInfo {
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;
@@ -37,6 +64,8 @@ export interface DatasetProfile {
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;
}
@@ -47,6 +76,7 @@ const naProfile = (size: number): DatasetProfile => ({
columnCount: null,
columns: [],
columnTypes: [],
columnStats: [],
size,
});
@@ -59,6 +89,45 @@ const SAMPLE_SIZE = 200;
const sampleRows = <T>(rows: ReadonlyArray<T>): ReadonlyArray<T> =>
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<string>();
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;
@@ -84,20 +153,25 @@ export function profileData(
if (columns.length === 0) return naProfile(size);
const sample = sampleRows(rows);
// TODO (backlog: docs/chart-builder-research.md §8 — A3/A4 enabler): in this same
// sample pass, also derive a capped per-column distinct count (cardinality, cap ~50)
// and numeric extent (min/max → sign), surfaced on DatasetProfile, to power the Chart
// Builder's crowded-legend / high-cardinality warnings and its negative-value Size guard.
const columnTypes = columns.map((name) => ({
name,
type: inferColumnType(sample.map((r) => r[name])),
}));
// 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,
};
}