From 3cecb23ea1e2e6c00718af9c95e4fe8e097d5439 Mon Sep 17 00:00:00 2001 From: Mplan Date: Fri, 12 Jun 2026 15:39:46 +0800 Subject: [PATCH] feat: add version flag, fix --float parsing, and optimize distributions --- src/args.ts | 57 ++++++++++++++++---------------- src/dist.ts | 93 ++++++++++++++++++++++++++++++++++++++++++---------- src/help.ts | 20 +++++++---- src/main.ts | 17 +++++----- src/stdin.ts | 8 ++--- src/types.ts | 16 ++++----- 6 files changed, 136 insertions(+), 75 deletions(-) diff --git a/src/args.ts b/src/args.ts index 5e3adcd..8ffdac8 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,17 +1,17 @@ import { type Options, type Dist, DISTS, defaultOptions } from "./types"; -import { showHelp } from "./help"; +import { validateOptions } from "./dist"; export function showError(msg: string): never { - console.error(`rand: ${msg}\nTry 'rand --help' for usage.`); - process.exit(1); + throw new Error(`${msg}\nTry 'rand --help' for usage.`); } -export function parseArgs(raw: string[]): { options: Options; help: boolean } { +export function parseArgs(raw: string[]): { options: Options; help: boolean; version: boolean } { const positional: string[] = []; let count = 1; let decimals = 0; let dist: Dist = "uniform"; let help = false; + let version = false; let i = 0; while (i < raw.length) { @@ -23,10 +23,21 @@ export function parseArgs(raw: string[]): { options: Options; help: boolean } { continue; } + if (arg === "-V" || arg === "--version") { + version = true; + i++; + continue; + } + // -f (plain) → default 2 decimal places if (arg === "-f") { - decimals = 2; i++; + if (i < raw.length && /^\d+$/.test(raw[i]!)) { + decimals = parseInt(raw[i]!, 10); + i++; + } else { + decimals = 2; + } continue; } @@ -54,21 +65,21 @@ export function parseArgs(raw: string[]): { options: Options; help: boolean } { i++; if (i >= raw.length) showError("--dist requires a distribution name"); const name = raw[i]!; - if (!(DISTS as string[]).includes(name)) { + if (!DISTS.includes(name as Dist)) { showError(`unknown distribution: ${name}\n valid: ${DISTS.join(", ")}`); } dist = name as Dist; i++; continue; } - // -c, --count if (arg === "-c" || arg === "--count") { i++; if (i >= raw.length) showError("--count requires a value"); - const n = parseInt(raw[i]!, 10); - if (isNaN(n) || n < 1) showError(`invalid count: ${raw[i]}`); - count = n; + const c = parseInt(raw[i]!, 10); + if (isNaN(c) || c < 1) showError(`invalid count: ${raw[i]}`); + if (c > 1_000_000) showError(`count must be <= 1,000,000, got ${c}`); + count = c; i++; continue; } @@ -103,8 +114,7 @@ export function parseArgs(raw: string[]): { options: Options; help: boolean } { opts.count = count; opts.decimals = decimals; applyPositionals(opts, positional); - - return { options: opts, help }; + return { options: opts, help, version }; } export function applyPositionals(opts: Options, args: string[]): void { @@ -121,7 +131,6 @@ export function applyPositionals(opts: Options, args: string[]): void { } else { showError(`uniform expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); } - if (opts.min > opts.max) showError(`min (${opts.min}) > max (${opts.max})`); break; } case "normal": { @@ -134,7 +143,6 @@ export function applyPositionals(opts: Options, args: string[]): void { } else { showError(`normal expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); } - if (opts.stddev <= 0) showError(`stddev must be > 0, got ${opts.stddev}`); break; } case "binomial": { @@ -147,10 +155,6 @@ export function applyPositionals(opts: Options, args: string[]): void { } else { showError(`binomial expects 0–2 args, got ${nums.length}: ${args.join(" ")}`); } - if (opts.trials < 0 || !Number.isInteger(opts.trials)) { - showError(`trials must be a non-negative integer, got ${opts.trials}`); - } - if (opts.prob < 0 || opts.prob > 1) showError(`prob must be 0–1, got ${opts.prob}`); break; } case "poisson": { @@ -160,7 +164,6 @@ export function applyPositionals(opts: Options, args: string[]): void { } else { showError(`poisson expects 0–1 arg, got ${nums.length}: ${args.join(" ")}`); } - if (opts.lambda <= 0) showError(`lambda must be > 0, got ${opts.lambda}`); break; } case "exponential": { @@ -170,7 +173,6 @@ export function applyPositionals(opts: Options, args: string[]): void { } else { showError(`exponential expects 0–1 arg, got ${nums.length}: ${args.join(" ")}`); } - if (opts.lambda <= 0) showError(`lambda must be > 0, got ${opts.lambda}`); break; } case "hypergeometric": { @@ -181,18 +183,15 @@ export function applyPositionals(opts: Options, args: string[]): void { if (nums.length > 3) { showError(`hypergeometric expects 0–3 args, got ${nums.length}: ${args.join(" ")}`); } - if (opts.popSize < 0 || !Number.isInteger(opts.popSize)) { - showError(`population size N must be a non-negative integer, got ${opts.popSize}`); - } - if (opts.successes < 0 || opts.successes > opts.popSize || !Number.isInteger(opts.successes)) { - showError(`successes K must be 0–N, got ${opts.successes} (N=${opts.popSize})`); - } - if (opts.draws < 0 || opts.draws > opts.popSize || !Number.isInteger(opts.draws)) { - showError(`draws n must be 0–N, got ${opts.draws} (N=${opts.popSize})`); - } break; } } + + try { + validateOptions(opts); + } catch (e) { + showError((e as Error).message); + } } function parseNum(s: string): number { diff --git a/src/dist.ts b/src/dist.ts index b8311b1..b21251f 100644 --- a/src/dist.ts +++ b/src/dist.ts @@ -4,16 +4,34 @@ import { type Options } from "./types"; // Individual distribution samplers // --------------------------------------------------------------------------- -/** Box-Muller transform. Each call consumes 2 uniform randoms. */ +// Cached second normal deviate from Box-Muller transform. +let spareNormal: number | null = null; + +/** Box-Muller with pair caching: uses half the RNG calls of naive Box-Muller. */ function normalRandom(mean: number, stddev: number): number { + if (spareNormal !== null) { + const v = spareNormal; + spareNormal = null; + return mean + stddev * v; + } let u1 = Math.random(); - while (u1 === 0) u1 = Math.random(); // avoid log(0) + for (let attempt = 0; u1 === 0 && attempt < 100; attempt++) { + u1 = Math.random(); + } + if (u1 === 0) u1 = Number.EPSILON; const u2 = Math.random(); - return mean + stddev * Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + const r = Math.sqrt(-2 * Math.log(u1)); + spareNormal = r * Math.sin(2 * Math.PI * u2); + return mean + stddev * r * Math.cos(2 * Math.PI * u2); } -/** Sum of Bernoulli trials. */ +/** Bernoulli trials; Normal approximation when n>10_000 and np, n(1-p) both >5. */ function binomialRandom(n: number, p: number): number { + if (n > 10_000 && n * p > 5 && n * (1 - p) > 5) { + const mean = n * p; + const stddev = Math.sqrt(n * p * (1 - p)); + return Math.max(0, Math.min(n, Math.round(normalRandom(mean, stddev)))); + } let s = 0; for (let i = 0; i < n; i++) { if (Math.random() < p) s++; @@ -21,8 +39,11 @@ function binomialRandom(n: number, p: number): number { return s; } -/** Knuth's algorithm. */ +/** Knuth's algorithm; Normal approximation for λ > 100 (avoids exp underflow). */ function poissonRandom(lambda: number): number { + if (lambda > 100) { + return Math.max(0, Math.round(normalRandom(lambda, Math.sqrt(lambda)))); + } const L = Math.exp(-lambda); let k = 0; let p = 1; @@ -33,9 +54,9 @@ function poissonRandom(lambda: number): number { return k - 1; } -/** Inverse CDF. */ +/** Inverse CDF. Caller must ensure λ > 0. */ function exponentialRandom(lambda: number): number { - return -Math.log(Math.random()) / lambda; + return -Math.log(Math.random() || Number.EPSILON) / lambda; } /** Urn model — simulate drawing without replacement. */ @@ -58,31 +79,67 @@ function hypergeometricRandom(N: number, K: number, n: number): number { // Dispatcher // --------------------------------------------------------------------------- -export function generate(opts: Options): number[] { - const results: number[] = []; +export function validateOptions(opts: Options): void { + if (!Number.isInteger(opts.count) || opts.count < 1) { + throw new Error(`invalid count: ${opts.count}`); + } + if (!Number.isInteger(opts.decimals) || opts.decimals < 0 || opts.decimals > 100) { + throw new Error(`decimals must be 0–100, got ${opts.decimals}`); + } + switch (opts.dist) { + case "uniform": + if (opts.min > opts.max) throw new Error(`min (${opts.min}) > max (${opts.max})`); + break; + case "normal": + if (opts.stddev <= 0) throw new Error(`stddev must be > 0, got ${opts.stddev}`); + break; + case "binomial": + if (opts.trials < 0 || !Number.isInteger(opts.trials)) + throw new Error(`trials must be a non-negative integer, got ${opts.trials}`); + if (opts.prob < 0 || opts.prob > 1) + throw new Error(`prob must be 0–1, got ${opts.prob}`); + break; + case "poisson": + case "exponential": + if (opts.lambda <= 0) throw new Error(`lambda must be > 0, got ${opts.lambda}`); + break; + case "hypergeometric": + if (opts.popSize < 0 || !Number.isInteger(opts.popSize)) + throw new Error(`population size N must be a non-negative integer, got ${opts.popSize}`); + if (opts.successes < 0 || opts.successes > opts.popSize || !Number.isInteger(opts.successes)) + throw new Error(`successes K must be 0–N, got ${opts.successes} (N=${opts.popSize})`); + if (opts.draws < 0 || opts.draws > opts.popSize || !Number.isInteger(opts.draws)) + throw new Error(`draws n must be 0–N, got ${opts.draws} (N=${opts.popSize})`); + break; + } +} + +export function* generate(opts: Options): Generator { + validateOptions(opts); for (let i = 0; i < opts.count; i++) { + let v: number; switch (opts.dist) { case "uniform": - results.push(Math.random() * (opts.max - opts.min) + opts.min); + v = opts.decimals === 0 + ? Math.floor(Math.random() * (opts.max - opts.min + 1)) + opts.min + : Math.random() * (opts.max - opts.min) + opts.min; break; case "normal": - results.push(normalRandom(opts.mean, opts.stddev)); + v = normalRandom(opts.mean, opts.stddev); break; case "binomial": - results.push(binomialRandom(opts.trials, opts.prob)); + v = binomialRandom(opts.trials, opts.prob); break; case "poisson": - results.push(poissonRandom(opts.lambda)); + v = poissonRandom(opts.lambda); break; case "exponential": - results.push(exponentialRandom(opts.lambda)); + v = exponentialRandom(opts.lambda); break; case "hypergeometric": - results.push( - hypergeometricRandom(opts.popSize, opts.successes, opts.draws), - ); + v = hypergeometricRandom(opts.popSize, opts.successes, opts.draws); break; } + yield opts.decimals > 0 ? v : Math.round(v); } - return results; } diff --git a/src/help.ts b/src/help.ts index ae82ec1..365697a 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,3 +1,5 @@ +import { VERSION } from "./types"; + export const HELP = `rand — Generate random numbers Usage: @@ -5,19 +7,20 @@ Usage: Options: -c, --count Number of random numbers to output (default: 1) - -f[N] Decimal places: -f = -f2 (default: 2) + -f[N] Decimal places: -f = -f2, -f3 = 3 places --float [N] Long form of -f -d, --dist Distribution (default: uniform) -h, --help Show this help + -V, --version Show version Distributions and their positional args: - uniform rand [min] [max] (default: 0 100) + uniform rand [min] [max] (default: 0 100) normal rand -d normal [mean] [stddev] (default: 0 1) - binomial rand -d binomial [n] [p] (default: 10 0.5) - poisson rand -d poisson [lambda] (default: 1) - exponential rand -d exponential [lambda] (default: 1) + binomial rand -d binomial [n] [p] (default: 10 0.5) + poisson rand -d poisson [lambda] (default: 1) + exponential rand -d exponential [lambda] (default: 1) hypergeometric rand -d hypergeometric [N] [K] [n] - (default: 100 50 10) + (default: 100 50 10) Examples: rand # uniform 0–100 @@ -33,3 +36,8 @@ export function showHelp(): never { console.log(HELP); process.exit(0); } + +export function showVersion(): never { + console.log(`rand v${VERSION}`); + process.exit(0); +} diff --git a/src/main.ts b/src/main.ts index 1e8a7ff..326d12f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,13 @@ import { parseArgs, applyPositionals } from "./args"; -import { showHelp } from "./help"; +import { showHelp, showVersion } from "./help"; import { readStdin, parseStdinNumbers } from "./stdin"; import { generate } from "./dist"; -export async function main(): Promise { +async function main(): Promise { const argv = Bun.argv.slice(2); - const { options, help } = parseArgs(argv); + const { options, help, version } = parseArgs(argv); if (help) showHelp(); + if (version) showVersion(); // If stdin has data, parse numbers and apply as positional overrides if (!process.stdin.isTTY) { @@ -17,13 +18,13 @@ export async function main(): Promise { } } - const results = generate(options); - for (const r of results) { - console.log(options.decimals > 0 ? r.toFixed(options.decimals) : Math.floor(r)); + for (const r of generate(options)) { + console.log(options.decimals > 0 ? r.toFixed(options.decimals) : r); } } -main().catch((err: Error) => { - console.error(`rand: ${err.message}`); +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`rand: ${msg}`); process.exit(1); }); diff --git a/src/stdin.ts b/src/stdin.ts index 8c48881..59d54c0 100644 --- a/src/stdin.ts +++ b/src/stdin.ts @@ -1,11 +1,11 @@ export async function readStdin(): Promise { const decoder = new TextDecoder(); - let buf = ""; + const parts: string[] = []; for await (const chunk of Bun.stdin.stream()) { - buf += decoder.decode(chunk, { stream: true }); + parts.push(decoder.decode(chunk, { stream: true })); } - buf += decoder.decode(); // flush - return buf; + parts.push(decoder.decode()); // flush + return parts.join(""); } /** Parse space-separated numbers from stdin. Returns number[] (may be empty). */ diff --git a/src/types.ts b/src/types.ts index cd6b915..b74e689 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,15 @@ -export type Dist = - | "uniform" - | "normal" - | "binomial" - | "poisson" - | "exponential" - | "hypergeometric"; - -export const DISTS: Dist[] = [ +export const DISTS = [ "uniform", "normal", "binomial", "poisson", "exponential", "hypergeometric", -]; +] as const; + +export type Dist = (typeof DISTS)[number]; + +export const VERSION = "1.0.0"; export interface Options { dist: Dist;