Skip to content

Commit 5f1b102

Browse files
paddymulclaude
andauthored
feat(duckdb-node): histogram summary stat (numeric .01–.99 + categorical) (#939)
* test(duckdb-node): histogram bars — numeric .01–.99 + categorical (failing) Add the histogram-stat contract tests ahead of the implementation: - histogram.test.ts: pure math ported from the Python producer's fixtures (histogram_test.py / polars_categorical_histogram_test.py) — numpy round-half-to-even, SI-prefix bucket labels, numeric tail/meat bars, and the categorical top-N/longtail/unique/NA bars on the 0–100 scale. - histogramSql.test.ts: the quantile/bin SQL builders and row parsers. - backend/columnConfig: assert the injected `histogram` pinned row (after dtype, displayer 'histogram') and per-column bar lists. - spike.duckdb.test.ts: real-DuckDB numeric histogram reproduces numpy's 1st/99th-percentile normalized populations, plus a categorical column. These reference src/histogram.ts + src/histogramSql.ts (not yet added), so the suite is red until the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(duckdb-node): histogram summary stat for numeric + categorical columns Compute the `histogram` bar list the viewer renders through the `displayer: 'histogram'` pinned row, matching buckaroo's pandas/polars producer (customizations/histogram.py) so a DuckDB column shows the same bars. - histogram.ts: faithful TS port of the Python math — numeric path clips the meat to the 1st/99th percentile (np.quantile == quantile_cont), 10 bins, low/high tail markers and an NA bucket; categorical path takes the top-7 categories plus longtail/unique/NA. All percentages 0–100, rounded with numpy round-half-to-even, SI-prefix bucket labels. - histogramSql.ts: per-column quantile + equal-width-bin SQL (DuckDB has no width_bucket, so floor((v-min)/width) clamped to the last bin reproduces np.histogram's edges) and the top-N value-counts query, plus the dispatcher that picks numeric vs categorical exactly like histogram.py:histogram. A failure on any single column yields an empty histogram, never a broken initial_state. - DuckSource gains a `queryRows` seam (implemented in the node-api adapter) for these small scalar aggregates; the no-coercion row path is untouched. - backend injects the `histogram` row right after dtype; columnConfig pins it with the histogram displayer (DefaultMainStyling.pinned_rows parity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(duckdb-node): unify histogram dispatch into one lazy buildHistogram computeColumnHistogram reimplemented the numeric-vs-categorical dispatch already in buildHistogram, so the two copies could drift and the production numeric path was only exercised by the SKIP_DUCKDB-gated spike (never on CI). Make buildHistogram async with lazy fetchers (fetchNumericArgs/ fetchCategorical) so it keeps the short-circuit — the categorical query runs only when the numeric path doesn't win — and have computeColumnHistogram delegate to it with SQL-backed fetchers. The CI-run histogram.test.ts now drives the exact dispatcher that ships, and asserts the categorical fetch is skipped when numeric wins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(duckdb-node): null non-finite floats for stats; add dastardly-dataframe tests A DOUBLE column containing nan/±inf crashed initialState: DuckDB's SUMMARIZE runs STDDEV_SAMP, which throws "out of range" on any non-finite value and aborts the whole stats query (ddd_library.py:df_with_infinity, which pandas renders fine). Add RenamePlan.statsRelation — renamedRelation with non-finite floats nulled (CASE WHEN isfinite(col) ...) — and summarize over it. The rule: non-finite floats are missing for summary statistics. Only the stats path is guarded; the row (COPY) and histogram paths stay no-coercion. Real IEEE-754 types only; DECIMAL is fixed-point and can't be non-finite, so it's left untouched. Also add test/ddd.duckdb.test.ts porting the ddd pathologies that map to SQL columns (column named "index", ±inf/nan, HUGEINT > 2^63 lossiness, the ENUM/INTERVAL/TIME/DECIMAL/BLOB weird-types frame), plus CI-running unit tests for statsRelation (the ddd suite is SKIP_DUCKDB and doesn't gate CI). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 63b1372 commit 5f1b102

15 files changed

Lines changed: 1131 additions & 27 deletions

packages/buckaroo-duckdb-node/src/DuckSource.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface DuckSource {
2626
* natively and the caller never touches a `DuckDBValue`.
2727
*/
2828
copyToParquet(query: string): Promise<Uint8Array>;
29+
30+
/**
31+
* Run a read query → JSON row objects. Used for the small histogram
32+
* aggregates (quantiles, bin counts, value counts), whose results are scalar
33+
* counts/labels — not user row data — so the no-coercion rule does not apply.
34+
*/
35+
queryRows(sql: string): Promise<Array<Record<string, unknown>>>;
2936
}
3037

3138
/** One row of `DESCRIBE (<stmt>)`. DuckDB returns more columns; we use these. */

packages/buckaroo-duckdb-node/src/adapters/nodeApiDuckSource.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export function createNodeApiDuckSource(
6161
return reader.getRowObjectsJson() as unknown as SummarizeRow[];
6262
},
6363

64+
async queryRows(sql: string): Promise<Array<Record<string, unknown>>> {
65+
const reader = await connection.runAndReadAll(sql);
66+
return reader.getRowObjectsJson();
67+
},
68+
6469
async copyToParquet(query: string): Promise<Uint8Array> {
6570
const dir = await mkdtemp(join(baseTmp, 'buckaroo-duck-'));
6671
const file = join(dir, `${randomUUID()}.parquet`);

packages/buckaroo-duckdb-node/src/backend.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { buildRenamePlan, INDEX_COL, type RenamePlan } from './rename.js';
1818
import { effectiveQuery, windowedQuery, type QueryTransform } from './query.js';
1919
import { buildDfViewerConfig } from './columnConfig.js';
2020
import { summarizeToSDType, sdTypeToStatRows } from './stats.js';
21+
import { computeHistograms } from './histogramSql.js';
2122
import type {
2223
BuckarooOptions,
2324
BuckarooState,
@@ -96,16 +97,30 @@ export class DuckBackend {
9697
async initialState(): Promise<InitialStateMessage> {
9798
const plan = await this.ensurePlan();
9899

99-
// Stats over the renamed relation; column names come back as aliases. The
100+
// Stats over the stats-safe relation (non-finite floats nulled so SUMMARIZE's
101+
// STDDEV_SAMP doesn't overflow); column names come back as aliases. The
100102
// relation includes the synthesized, non-null `index` column, so its
101103
// SUMMARIZE count is the total row count — no extra count query needed.
102-
const summarizeRows = await this.source.summarize(plan.renamedRelation(this.effectiveSql));
104+
const summarizeRows = await this.source.summarize(plan.statsRelation(this.effectiveSql));
103105
const indexRow = summarizeRows.find((r) => r.column_name === INDEX_COL);
104106
this.totalRows = indexRow ? Number(indexRow.count) : 0;
105107

106108
const sd = summarizeToSDType(summarizeRows);
107109
const statRows = sdTypeToStatRows(sd);
108110

111+
// The histogram bars are a per-column list of objects, not a scalar stat,
112+
// so they ride as their own pinned row (injected right after dtype, the
113+
// position the `histogram` pin in columnConfig expects) rather than through
114+
// the SDType pivot.
115+
const histos = await computeHistograms(
116+
this.source,
117+
plan.renamedRelation(this.effectiveSql),
118+
plan,
119+
sd,
120+
this.totalRows,
121+
);
122+
statRows.splice(1, 0, { [INDEX_COL]: 'histogram', level_0: 'histogram', ...histos });
123+
109124
const dfViewerConfig = buildDfViewerConfig(plan);
110125

111126
return {

packages/buckaroo-duckdb-node/src/columnConfig.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,45 @@
22
* Build the `df_viewer_config` (column_config + pinned_rows + left_col_configs)
33
* from the rename plan.
44
*
5-
* Pinned rows reference only the v1 stats this backend actually computes from
6-
* `SUMMARIZE` (see stats.ts) so no pinned row renders empty. Histogram rows are
7-
* a fast-follow once the histogram SQL lands.
5+
* Pinned rows reference only stats this backend actually computes — the
6+
* `SUMMARIZE`-derived stats (see stats.ts) plus the `histogram` row the backend
7+
* injects — so no pinned row renders empty.
88
*/
99

1010
import type { RenamePlan } from './rename.js';
1111
import { INDEX_COL } from './rename.js';
1212
import { duckTypeToColType, displayerForColType } from './duckTypes.js';
13-
import type { ColumnConfig, DFViewerConfig, PinnedRowConfig } from './wireTypes.js';
13+
import type {
14+
ColumnConfig,
15+
DFViewerConfig,
16+
DisplayerArgs,
17+
PinnedRowConfig,
18+
} from './wireTypes.js';
1419

1520
/**
16-
* The stat keys pinned in v1, in display order. Each must match a stat name
17-
* produced by stats.ts (and therefore a wide `{col}__{stat}` column).
18-
* `dtype` renders via `obj`; the rest `inherit` the column's own displayer
19-
* (styling_helpers.py: obj_/inherit_).
21+
* The stat keys pinned in v1, in display order. `dtype` renders via `obj`,
22+
* `histogram` via the histogram displayer, and the rest `inherit` the column's
23+
* own displayer (styling_helpers.py: obj_/inherit_/pinned_histogram). The
24+
* histogram row sits right after dtype to match DefaultMainStyling.pinned_rows.
2025
*/
21-
export const V1_PINNED_STATS: ReadonlyArray<{ stat: string; inherit: boolean }> = [
22-
{ stat: 'dtype', inherit: false },
23-
{ stat: 'null_count', inherit: true },
24-
{ stat: 'distinct_count', inherit: true },
25-
{ stat: 'mean', inherit: true },
26-
{ stat: 'std', inherit: true },
27-
{ stat: 'min', inherit: true },
28-
{ stat: 'q25', inherit: true },
29-
{ stat: 'q50', inherit: true },
30-
{ stat: 'q75', inherit: true },
31-
{ stat: 'max', inherit: true },
26+
export const V1_PINNED_STATS: ReadonlyArray<{ stat: string; displayer_args: DisplayerArgs }> = [
27+
{ stat: 'dtype', displayer_args: { displayer: 'obj' } },
28+
{ stat: 'histogram', displayer_args: { displayer: 'histogram' } },
29+
{ stat: 'null_count', displayer_args: { displayer: 'inherit' } },
30+
{ stat: 'distinct_count', displayer_args: { displayer: 'inherit' } },
31+
{ stat: 'mean', displayer_args: { displayer: 'inherit' } },
32+
{ stat: 'std', displayer_args: { displayer: 'inherit' } },
33+
{ stat: 'min', displayer_args: { displayer: 'inherit' } },
34+
{ stat: 'q25', displayer_args: { displayer: 'inherit' } },
35+
{ stat: 'q50', displayer_args: { displayer: 'inherit' } },
36+
{ stat: 'q75', displayer_args: { displayer: 'inherit' } },
37+
{ stat: 'max', displayer_args: { displayer: 'inherit' } },
3238
];
3339

3440
export function buildPinnedRows(): PinnedRowConfig[] {
35-
return V1_PINNED_STATS.map(({ stat, inherit }) => ({
41+
return V1_PINNED_STATS.map(({ stat, displayer_args }) => ({
3642
primary_key_val: stat,
37-
displayer_args: inherit ? { displayer: 'inherit' } : { displayer: 'obj' },
43+
displayer_args,
3844
}));
3945
}
4046

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* Histogram bars: the `histogram` summary stat the viewer renders through the
3+
* `displayer: 'histogram'` pinned row.
4+
*
5+
* This is a faithful TS port of buckaroo's Python producer
6+
* (`customizations/histogram.py`) so a DuckDB-backed column renders the same
7+
* bars as the pandas/polars backends:
8+
*
9+
* - NUMERIC: a low-tail marker, 10 "meat" buckets over the 1st–99th
10+
* percentile range (population %), a high-tail marker, then an NA bucket.
11+
* - CATEGORICAL: the top-7 categories (`cat_pop` %), then `longtail`,
12+
* `unique`, and `NA` buckets.
13+
*
14+
* All percentages are on the 0–100 scale and rounded with numpy's
15+
* round-half-to-even (`np.round`) so the labels match the Python output
16+
* bit-for-bit. The SQL that produces the raw inputs lives in `histogramSql.ts`.
17+
*/
18+
19+
import type { HistogramBar } from './wireTypes.js';
20+
21+
/** numpy `np.round(x, 0)` — round half to even (banker's rounding). */
22+
export function npRound0(x: number): number {
23+
const floor = Math.floor(x);
24+
const diff = x - floor;
25+
if (diff < 0.5) return floor;
26+
if (diff > 0.5) return floor + 1;
27+
// exact .5 → round to the even neighbour
28+
return floor % 2 === 0 ? floor : floor + 1;
29+
}
30+
31+
/** histogram.py:_trim — strip trailing zeros after the decimal point only. */
32+
function trim(s: string): string {
33+
if (s.includes('.')) {
34+
return s.replace(/0+$/, '').replace(/\.$/, '');
35+
}
36+
return s;
37+
}
38+
39+
/** histogram.py:fmt_num — SI prefix (K/M/B/T) + step-based precision. */
40+
export function fmtNum(value: number, step: number, ref: number): string {
41+
if (step > 0 && Math.abs(value) < step * 1e-9) {
42+
value = 0.0;
43+
}
44+
const tiers: Array<[number, string]> = [
45+
[1e12, 'T'],
46+
[1e9, 'B'],
47+
[1e6, 'M'],
48+
[1e3, 'K'],
49+
];
50+
for (const [threshold, suffix] of tiers) {
51+
if (ref >= threshold) {
52+
const scaled = value / threshold;
53+
const stepS = step / threshold;
54+
let dec = stepS > 0 ? Math.max(0, -Math.floor(Math.log10(stepS)) + 1) : 1;
55+
dec = Math.min(dec, 2);
56+
return trim(scaled.toFixed(dec)) + suffix;
57+
}
58+
}
59+
let dec = step > 0 ? Math.max(0, -Math.floor(Math.log10(step)) + 1) : 0;
60+
dec = Math.min(dec, 6);
61+
return trim(value.toFixed(dec));
62+
}
63+
64+
/**
65+
* histogram.py:_join_bounds — any negative bound switches the separator to
66+
* `<>`; the minus sign and en-dash are near-identical glyphs and `-0.5–0.5`
67+
* reads as a double dash.
68+
*/
69+
function joinBounds(loS: string, hiS: string): string {
70+
const sep = loS.startsWith('-') || hiS.startsWith('-') ? '<>' : '–';
71+
return `${loS}${sep}${hiS}`;
72+
}
73+
74+
/** histogram.py:fmt_bucket */
75+
export function fmtBucket(lo: number, hi: number, step: number, ref: number): string {
76+
return joinBounds(fmtNum(lo, step, ref), fmtNum(hi, step, ref));
77+
}
78+
79+
/**
80+
* histogram.py:fmt_tail_bucket — per-bound SI prefix (ref = |bound|) so a far
81+
* outlier bound doesn't drag a small bound down to '0K'.
82+
*/
83+
function fmtTailBucket(lo: number, hi: number, step: number): string {
84+
return joinBounds(fmtNum(lo, step, Math.abs(lo)), fmtNum(hi, step, Math.abs(hi)));
85+
}
86+
87+
/** histogram.py:numeric_histogram_labels — one label per bucket from the edges. */
88+
export function numericHistogramLabels(endpoints: number[]): string[] {
89+
let left = endpoints[0];
90+
const labels: string[] = [];
91+
const minVal = endpoints[0];
92+
const maxVal = endpoints[endpoints.length - 1];
93+
const step = (maxVal - minVal) / Math.max(endpoints.length - 1, 1);
94+
const ref = Math.max(Math.abs(minVal), Math.abs(maxVal));
95+
for (const edge of endpoints.slice(1)) {
96+
labels.push(fmtBucket(left, edge, step, ref));
97+
left = edge;
98+
}
99+
return labels;
100+
}
101+
102+
/**
103+
* The raw numeric-histogram inputs, mirroring Python's `histogram_args`:
104+
* - `meatCounts` — np.histogram bin counts (10 bins).
105+
* - `endpoints` — the 11 bin edges (linspace over the meat range).
106+
* - `lowTail`/`highTail`— the 1st/99th percentile cut points.
107+
* `normalizedPopulations` is derived here (counts / sum) so callers only carry
108+
* the SQL-shaped values.
109+
*/
110+
export interface NumericHistogramArgs {
111+
meatCounts: number[];
112+
endpoints: number[];
113+
lowTail: number;
114+
highTail: number;
115+
}
116+
117+
/** histogram.py:numeric_histogram */
118+
export function numericHistogram(
119+
args: NumericHistogramArgs,
120+
min: number,
121+
max: number,
122+
nanPer: number,
123+
): HistogramBar[] {
124+
const naObs: HistogramBar = { name: 'NA', NA: npRound0(nanPer * 100) };
125+
if (nanPer === 1.0) return [naObs];
126+
127+
const { meatCounts, endpoints, lowTail, highTail } = args;
128+
const total = meatCounts.reduce((a, b) => a + b, 0);
129+
const normalizedPop = meatCounts.map((c) => (total > 0 ? c / total : 0));
130+
const labels = numericHistogramLabels(endpoints);
131+
132+
const eLo = endpoints[0];
133+
const eHi = endpoints[endpoints.length - 1];
134+
const step = (eHi - eLo) / Math.max(endpoints.length - 1, 1);
135+
136+
const histo: HistogramBar[] = [];
137+
histo.push({ name: fmtTailBucket(min, lowTail, step), tail: 1 });
138+
labels.forEach((label, i) => {
139+
histo.push({ name: label, population: npRound0(normalizedPop[i] * 100) });
140+
});
141+
histo.push({ name: fmtTailBucket(highTail, max, step), tail: 1 });
142+
if (nanPer > 0.0) histo.push(naObs);
143+
return histo;
144+
}
145+
146+
/** A distinct value and its row count, as returned by the value-counts query. */
147+
export interface ValueCount {
148+
name: string;
149+
count: number;
150+
}
151+
152+
/**
153+
* histogram.py:categorical_histogram (with categorical_dict inlined).
154+
*
155+
* `top` is the top-N value counts (already sorted desc); `restSum` is the
156+
* summed count of every other distinct value; `uniqueCount` is the number of
157+
* distinct values that occur exactly once (over the whole column). `length`
158+
* includes nulls.
159+
*/
160+
export function categoricalHistogram(
161+
length: number,
162+
top: ValueCount[],
163+
restSum: number,
164+
uniqueCount: number,
165+
nanPer: number,
166+
): HistogramBar[] {
167+
const histo: HistogramBar[] = [];
168+
for (const { name, count } of top) {
169+
const percent = npRound0((count / length) * 100);
170+
if (percent > 0.3) {
171+
histo.push({ name: String(name), cat_pop: percent });
172+
}
173+
}
174+
const longTail = restSum - uniqueCount;
175+
if (longTail > 0) {
176+
histo.push({ name: 'longtail', longtail: npRound0((longTail / length) * 100) });
177+
}
178+
if (uniqueCount > 0) {
179+
histo.push({ name: 'unique', unique: npRound0((uniqueCount / length) * 100) });
180+
}
181+
if (nanPer > 0.0) {
182+
histo.push({ name: 'NA', NA: npRound0(nanPer * 100) });
183+
}
184+
return histo;
185+
}
186+
187+
/** The categorical inputs `buildHistogram` needs (see `parseCategorical`). */
188+
export interface CategoricalArgs {
189+
top: ValueCount[];
190+
restSum: number;
191+
uniqueCount: number;
192+
}
193+
194+
/**
195+
* histogram.py:histogram — pick the numeric path only when the column is
196+
* numeric, has more than 5 distinct values, valid histogram args, and the
197+
* resulting numeric histogram has more than 5 bars; otherwise fall back to the
198+
* categorical histogram.
199+
*
200+
* The numeric and categorical inputs are fetched lazily so the categorical
201+
* query only runs when the numeric path doesn't win — matching histogram.py's
202+
* short-circuit. This is the single dispatcher: the production pipeline
203+
* (histogramSql.ts:computeColumnHistogram) supplies SQL-backed fetchers, the
204+
* unit tests supply in-memory ones, so the same branch logic is what ships.
205+
*/
206+
export async function buildHistogram(opts: {
207+
isNumeric: boolean;
208+
distinctCount: number;
209+
length: number;
210+
nanPer: number;
211+
min: number | null;
212+
max: number | null;
213+
fetchNumericArgs: () => Promise<NumericHistogramArgs | null>;
214+
fetchCategorical: () => Promise<CategoricalArgs>;
215+
}): Promise<HistogramBar[]> {
216+
const { isNumeric, distinctCount, length, nanPer, min, max, fetchNumericArgs, fetchCategorical } =
217+
opts;
218+
if (isNumeric && distinctCount > 5 && min !== null && max !== null) {
219+
const numericArgs = await fetchNumericArgs();
220+
if (numericArgs) {
221+
const temp = numericHistogram(numericArgs, min, max, nanPer);
222+
if (temp.length > 5) return temp;
223+
}
224+
}
225+
const categorical = await fetchCategorical();
226+
return categoricalHistogram(length, categorical.top, categorical.restSum, categorical.uniqueCount, nanPer);
227+
}

0 commit comments

Comments
 (0)