Created
July 12, 2026 21:48
-
-
Save brandonhimpfen/1984150c1950f2adcb6517938448163b to your computer and use it in GitHub Desktop.
Compare exact and bitmap-based approximate distinct counts for CSV or JSONL columns while streaming in Node.js, including absolute and percentage error with no dependencies.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Compare exact and approximate distinct counts while streaming. | |
| * | |
| * The script calculates: | |
| * - Exact distinct count using an in-memory Set | |
| * - Approximate distinct count using a fixed-size bitmap | |
| * - Absolute error | |
| * - Percentage error | |
| * - Bitmap occupancy and saturation warnings | |
| * | |
| * Usage: | |
| * node node-compare-distinct-count-estimates.js data.csv \ | |
| * --format=csv \ | |
| * --columns=country,user_id | |
| * | |
| * cat data.jsonl | node node-compare-distinct-count-estimates.js \ | |
| * --format=jsonl \ | |
| * --columns=user.country,user.id \ | |
| * --bitmap-bits=1048576 | |
| * | |
| * Options: | |
| * --format=csv|jsonl Input format (default: jsonl) | |
| * --columns=a,b,c Columns to profile | |
| * --bitmap-bits=N Bitmap size per column in bits | |
| * (default: 1048576, or 128 KiB per column) | |
| * --delimiter=, CSV delimiter (default: comma) | |
| * | |
| * Important: | |
| * - The exact Set grows with the number of unique values. | |
| * - This comparison script is intended for test data, samples, and estimator | |
| * evaluation—not fully bounded-memory production profiling. | |
| * - CSV fields containing embedded physical newlines are not supported. | |
| * - No third-party dependencies. | |
| */ | |
| const crypto = require("crypto"); | |
| const fs = require("fs"); | |
| const readline = require("readline"); | |
| function usage() { | |
| console.error(`Usage: | |
| node node-compare-distinct-count-estimates.js <input.csv> \\ | |
| --format=csv \\ | |
| --columns=country,user_id | |
| cat input.jsonl | node node-compare-distinct-count-estimates.js \\ | |
| --format=jsonl \\ | |
| --columns=user.country,user.id \\ | |
| --bitmap-bits=1048576 | |
| Options: | |
| --format=csv|jsonl Input format (default: jsonl) | |
| --columns=a,b,c Columns to compare | |
| --bitmap-bits=N Bitmap bits per column (default: 1048576) | |
| --delimiter=, CSV delimiter (default: ,) | |
| `); | |
| } | |
| function parseArgs(argv) { | |
| const args = { | |
| file: null, | |
| format: "jsonl", | |
| columns: null, | |
| bitmapBits: 1_048_576, | |
| delimiter: ",", | |
| help: false, | |
| }; | |
| for (const arg of argv.slice(2)) { | |
| if (!args.file && !arg.startsWith("--")) { | |
| args.file = arg; | |
| continue; | |
| } | |
| if (arg.startsWith("--format=")) { | |
| args.format = arg.slice("--format=".length).toLowerCase(); | |
| continue; | |
| } | |
| if (arg.startsWith("--columns=")) { | |
| args.columns = arg | |
| .slice("--columns=".length) | |
| .split(",") | |
| .map((column) => column.trim()) | |
| .filter(Boolean); | |
| continue; | |
| } | |
| if (arg.startsWith("--bitmap-bits=")) { | |
| args.bitmapBits = Number( | |
| arg.slice("--bitmap-bits=".length) | |
| ); | |
| continue; | |
| } | |
| if (arg.startsWith("--delimiter=")) { | |
| args.delimiter = decodeDelimiter( | |
| arg.slice("--delimiter=".length) | |
| ); | |
| continue; | |
| } | |
| if (arg === "-h" || arg === "--help") { | |
| args.help = true; | |
| continue; | |
| } | |
| throw new Error(`Unknown argument: ${arg}`); | |
| } | |
| return args; | |
| } | |
| function decodeDelimiter(value) { | |
| const aliases = { | |
| "\\t": "\t", | |
| tab: "\t", | |
| comma: ",", | |
| pipe: "|", | |
| semicolon: ";", | |
| }; | |
| return aliases[value.toLowerCase()] ?? value; | |
| } | |
| function parseDelimitedLine(line, delimiter = ",") { | |
| const fields = []; | |
| let current = ""; | |
| let inQuotes = false; | |
| for (let i = 0; i < line.length; i += 1) { | |
| const char = line[i]; | |
| if (inQuotes) { | |
| if (char === '"') { | |
| if (line[i + 1] === '"') { | |
| current += '"'; | |
| i += 1; | |
| } else { | |
| inQuotes = false; | |
| } | |
| } else { | |
| current += char; | |
| } | |
| continue; | |
| } | |
| if (char === '"') { | |
| inQuotes = true; | |
| continue; | |
| } | |
| if (line.startsWith(delimiter, i)) { | |
| fields.push(current); | |
| current = ""; | |
| i += delimiter.length - 1; | |
| continue; | |
| } | |
| current += char; | |
| } | |
| if (inQuotes) { | |
| throw new Error("Unclosed quoted field"); | |
| } | |
| fields.push(current); | |
| return fields; | |
| } | |
| function getByPath(object, keyPath) { | |
| let current = object; | |
| for (const part of keyPath.split(".")) { | |
| if ( | |
| current === null || | |
| typeof current !== "object" || | |
| !Object.prototype.hasOwnProperty.call(current, part) | |
| ) { | |
| return { | |
| exists: false, | |
| value: undefined, | |
| }; | |
| } | |
| current = current[part]; | |
| } | |
| return { | |
| exists: true, | |
| value: current, | |
| }; | |
| } | |
| function stableStringify(value) { | |
| if (Array.isArray(value)) { | |
| return `[${value.map(stableStringify).join(",")}]`; | |
| } | |
| if (value && typeof value === "object") { | |
| const entries = Object.keys(value) | |
| .sort() | |
| .map( | |
| (key) => | |
| `${JSON.stringify(key)}:${stableStringify(value[key])}` | |
| ); | |
| return `{${entries.join(",")}}`; | |
| } | |
| return JSON.stringify(value); | |
| } | |
| function normalizeValue(value) { | |
| if (value === null) { | |
| return "null:null"; | |
| } | |
| if (Array.isArray(value)) { | |
| return `array:${stableStringify(value)}`; | |
| } | |
| if (typeof value === "object") { | |
| return `object:${stableStringify(value)}`; | |
| } | |
| return `${typeof value}:${String(value)}`; | |
| } | |
| function isEmpty(value) { | |
| return ( | |
| value === null || | |
| value === undefined || | |
| (typeof value === "string" && value.trim() === "") | |
| ); | |
| } | |
| function bitmapIndex(value, bitmapBits) { | |
| const digest = crypto | |
| .createHash("sha256") | |
| .update(value) | |
| .digest(); | |
| const high = digest.readUInt32BE(0); | |
| const low = digest.readUInt32BE(4); | |
| const hash = | |
| (BigInt(high) << 32n) | | |
| BigInt(low); | |
| return Number(hash % BigInt(bitmapBits)); | |
| } | |
| function createColumnState(bitmapBits) { | |
| return { | |
| exactValues: new Set(), | |
| bitmapBits, | |
| bitmap: Buffer.alloc(Math.ceil(bitmapBits / 8)), | |
| occupiedBits: 0, | |
| totalRows: 0, | |
| observedValues: 0, | |
| missing: 0, | |
| empty: 0, | |
| }; | |
| } | |
| function createStates(columns, bitmapBits) { | |
| return Object.fromEntries( | |
| columns.map((column) => [ | |
| column, | |
| createColumnState(bitmapBits), | |
| ]) | |
| ); | |
| } | |
| function setBitmapBit(state, bitIndex) { | |
| const byteIndex = Math.floor(bitIndex / 8); | |
| const mask = 1 << (bitIndex % 8); | |
| if ((state.bitmap[byteIndex] & mask) === 0) { | |
| state.bitmap[byteIndex] |= mask; | |
| state.occupiedBits += 1; | |
| } | |
| } | |
| function updateState(state, value, exists) { | |
| state.totalRows += 1; | |
| if (!exists) { | |
| state.missing += 1; | |
| return; | |
| } | |
| if (isEmpty(value)) { | |
| state.empty += 1; | |
| return; | |
| } | |
| state.observedValues += 1; | |
| const normalized = normalizeValue(value); | |
| state.exactValues.add(normalized); | |
| const index = bitmapIndex( | |
| normalized, | |
| state.bitmapBits | |
| ); | |
| setBitmapBit(state, index); | |
| } | |
| function estimateDistinct(state) { | |
| const unsetBits = | |
| state.bitmapBits - state.occupiedBits; | |
| if (unsetBits <= 0) { | |
| return null; | |
| } | |
| return ( | |
| -state.bitmapBits * | |
| Math.log(unsetBits / state.bitmapBits) | |
| ); | |
| } | |
| function roundNumber(value, decimalPlaces = 2) { | |
| if (value === null || !Number.isFinite(value)) { | |
| return value; | |
| } | |
| return Number(value.toFixed(decimalPlaces)); | |
| } | |
| function buildReport(states) { | |
| return Object.entries(states).map( | |
| ([column, state]) => { | |
| const exact = state.exactValues.size; | |
| const rawEstimate = estimateDistinct(state); | |
| const estimate = | |
| rawEstimate === null | |
| ? null | |
| : Math.round(rawEstimate); | |
| const absoluteError = | |
| estimate === null | |
| ? null | |
| : Math.abs(estimate - exact); | |
| const signedError = | |
| estimate === null | |
| ? null | |
| : estimate - exact; | |
| const percentageError = | |
| estimate === null | |
| ? null | |
| : exact === 0 | |
| ? 0 | |
| : (absoluteError / exact) * 100; | |
| const occupancy = | |
| state.occupiedBits / state.bitmapBits; | |
| return { | |
| column, | |
| total_rows: state.totalRows, | |
| observed_values: state.observedValues, | |
| missing: state.missing, | |
| empty: state.empty, | |
| exact_distinct: exact, | |
| approximate_distinct: estimate, | |
| signed_error: signedError, | |
| absolute_error: absoluteError, | |
| percentage_error: | |
| roundNumber(percentageError, 4), | |
| bitmap_bits: state.bitmapBits, | |
| bitmap_bytes: state.bitmap.length, | |
| occupied_bits: state.occupiedBits, | |
| bitmap_occupancy_pct: | |
| roundNumber(occupancy * 100, 2), | |
| saturated: rawEstimate === null, | |
| warning: | |
| occupancy >= 0.9 | |
| ? "Bitmap is highly saturated; increase --bitmap-bits." | |
| : occupancy >= 0.7 | |
| ? "Bitmap occupancy is high; estimate accuracy may be reduced." | |
| : null, | |
| }; | |
| } | |
| ); | |
| } | |
| async function runCSV(input, args) { | |
| const reader = readline.createInterface({ | |
| input, | |
| crlfDelay: Infinity, | |
| }); | |
| const states = createStates( | |
| args.columns, | |
| args.bitmapBits | |
| ); | |
| let headers = null; | |
| let columnIndexes = null; | |
| let lineNumber = 0; | |
| for await (const line of reader) { | |
| lineNumber += 1; | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| let fields; | |
| try { | |
| fields = parseDelimitedLine( | |
| line, | |
| args.delimiter | |
| ); | |
| } catch (error) { | |
| throw new Error( | |
| `Invalid delimited record on line ${lineNumber}: ${error.message}` | |
| ); | |
| } | |
| if (headers === null) { | |
| headers = fields.map((header) => | |
| header.trim() | |
| ); | |
| const duplicates = headers.filter( | |
| (header, index) => | |
| headers.indexOf(header) !== index | |
| ); | |
| if (duplicates.length > 0) { | |
| throw new Error( | |
| `Duplicate CSV header(s): ${[ | |
| ...new Set(duplicates), | |
| ].join(", ")}` | |
| ); | |
| } | |
| columnIndexes = Object.fromEntries( | |
| args.columns.map((column) => [ | |
| column, | |
| headers.indexOf(column), | |
| ]) | |
| ); | |
| continue; | |
| } | |
| for (const column of args.columns) { | |
| const index = columnIndexes[column]; | |
| const exists = | |
| index !== -1 && index < fields.length; | |
| updateState( | |
| states[column], | |
| exists ? fields[index] : undefined, | |
| exists | |
| ); | |
| } | |
| } | |
| if (headers === null) { | |
| throw new Error("No CSV header row found"); | |
| } | |
| return states; | |
| } | |
| async function runJSONL(input, args) { | |
| const reader = readline.createInterface({ | |
| input, | |
| crlfDelay: Infinity, | |
| }); | |
| const states = createStates( | |
| args.columns, | |
| args.bitmapBits | |
| ); | |
| let lineNumber = 0; | |
| for await (const line of reader) { | |
| lineNumber += 1; | |
| if (!line.trim()) { | |
| continue; | |
| } | |
| let record; | |
| try { | |
| record = JSON.parse(line); | |
| } catch (error) { | |
| throw new Error( | |
| `Invalid JSON on line ${lineNumber}: ${error.message}` | |
| ); | |
| } | |
| if ( | |
| record === null || | |
| typeof record !== "object" || | |
| Array.isArray(record) | |
| ) { | |
| throw new Error( | |
| `Invalid JSONL record on line ${lineNumber}: expected an object` | |
| ); | |
| } | |
| for (const column of args.columns) { | |
| const result = getByPath(record, column); | |
| updateState( | |
| states[column], | |
| result.value, | |
| result.exists | |
| ); | |
| } | |
| } | |
| return states; | |
| } | |
| function validateArgs(args) { | |
| if (!["csv", "jsonl"].includes(args.format)) { | |
| throw new Error( | |
| "--format must be csv or jsonl" | |
| ); | |
| } | |
| if (!args.columns || args.columns.length === 0) { | |
| throw new Error( | |
| "--columns must contain at least one column" | |
| ); | |
| } | |
| if ( | |
| !Number.isInteger(args.bitmapBits) || | |
| args.bitmapBits < 256 | |
| ) { | |
| throw new Error( | |
| "--bitmap-bits must be an integer of at least 256" | |
| ); | |
| } | |
| if (!args.delimiter) { | |
| throw new Error( | |
| "--delimiter cannot be empty" | |
| ); | |
| } | |
| } | |
| async function main() { | |
| const args = parseArgs(process.argv); | |
| if (args.help) { | |
| usage(); | |
| process.exit(0); | |
| } | |
| validateArgs(args); | |
| const input = args.file | |
| ? fs.createReadStream(args.file, { | |
| encoding: "utf8", | |
| }) | |
| : process.stdin; | |
| const states = | |
| args.format === "csv" | |
| ? await runCSV(input, args) | |
| : await runJSONL(input, args); | |
| console.log( | |
| JSON.stringify(buildReport(states), null, 2) | |
| ); | |
| } | |
| main().catch((error) => { | |
| console.error( | |
| "ERROR:", | |
| error && error.stack | |
| ? error.stack | |
| : error | |
| ); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment