diff --git a/docs/.vitepress/api-pages.ts b/docs/.vitepress/api-pages.ts index 3a4144123d5..fcea7f72b39 100644 --- a/docs/.vitepress/api-pages.ts +++ b/docs/.vitepress/api-pages.ts @@ -5,6 +5,7 @@ export const apiPages = [ { text: 'Faker', link: '/api/faker.html' }, { text: 'SimpleFaker', link: '/api/simpleFaker.html' }, { text: 'Randomizer', link: '/api/randomizer.html' }, + { text: 'Distributors', link: '/api/distributors.html' }, { text: 'Utilities', link: '/api/utils.html' }, { text: 'Modules', diff --git a/scripts/apidocs/generate.ts b/scripts/apidocs/generate.ts index 81021c5fbd3..f798c303209 100644 --- a/scripts/apidocs/generate.ts +++ b/scripts/apidocs/generate.ts @@ -8,6 +8,7 @@ import type { RawApiDocsPage } from './processing/class'; import { processModuleClasses, processProjectClasses, + processProjectDistributors, processProjectInterfaces, processProjectUtilities, } from './processing/class'; @@ -26,6 +27,7 @@ export function processComponents(project: Project): RawApiDocsPage[] { return [ ...processProjectClasses(project), ...processProjectInterfaces(project), + processProjectDistributors(project), processProjectUtilities(project), ...processModuleClasses(project), ]; diff --git a/scripts/apidocs/processing/class.ts b/scripts/apidocs/processing/class.ts index 33fa213e4cc..2b4ea2b80ff 100644 --- a/scripts/apidocs/processing/class.ts +++ b/scripts/apidocs/processing/class.ts @@ -1,4 +1,5 @@ import type { ClassDeclaration, InterfaceDeclaration, Project } from 'ts-morph'; +import { wrapCode } from '../utils/markdown'; import { required, valuesForKeys } from '../utils/value-checks'; import { newProcessingError } from './error'; import type { JSDocableLikeNode } from './jsdocs'; @@ -12,6 +13,7 @@ import type { RawApiDocsMethod } from './method'; import { processClassConstructors, processClassMethods, + processDistributorFunctions, processInterfaceMethods, processUtilityFunctions, } from './method'; @@ -196,6 +198,34 @@ export function processProjectUtilities(project: Project): RawApiDocsPage { }; } +// Distributors + +export function processProjectDistributors(project: Project): RawApiDocsPage { + console.log(`- Distributors`); + + const distributor = required( + project + .getSourceFile('src/distributors/distributor.ts') + ?.getTypeAliases()[0], + 'Distributor' + ); + + const jsdocs = getJsDocs(distributor); + const description = `${getDescription(jsdocs)} + +${wrapCode(distributor.getText().replace(/export /, ''))}`; + + return { + title: 'Distributors', + camelTitle: 'distributors', + category: undefined, + deprecated: undefined, + description, + examples: getExamples(jsdocs), + methods: processDistributorFunctions(project), + }; +} + // Helpers function preparePage( diff --git a/scripts/apidocs/processing/method.ts b/scripts/apidocs/processing/method.ts index 51879caba64..4631af9d2cd 100644 --- a/scripts/apidocs/processing/method.ts +++ b/scripts/apidocs/processing/method.ts @@ -144,6 +144,17 @@ export function processUtilityFunctions(project: Project): RawApiDocsMethod[] { ); } +export function processDistributorFunctions( + project: Project +): RawApiDocsMethod[] { + return processMethodLikes( + Object.values(getAllFunctions(project)).filter((fn) => + fn.getSourceFile().getFilePath().includes('/src/distributors/') + ), + (f) => f.getNameOrThrow() + ); +} + // Method-likes type MethodLikeDeclaration = SignatureLikeDeclaration & diff --git a/scripts/apidocs/utils/markdown.ts b/scripts/apidocs/utils/markdown.ts index 2787a611bf4..1c19b51b7f9 100644 --- a/scripts/apidocs/utils/markdown.ts +++ b/scripts/apidocs/utils/markdown.ts @@ -26,6 +26,12 @@ const htmlSanitizeOptions: sanitizeHtml.IOptions = { 'span', 'strong', 'ul', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', ], allowedAttributes: { a: ['href', 'target', 'rel'], @@ -33,6 +39,9 @@ const htmlSanitizeOptions: sanitizeHtml.IOptions = { div: ['class'], pre: ['class', 'v-pre', 'tabindex'], span: ['class', 'style'], + table: ['tabindex'], + th: ['style'], + td: ['style'], }, selfClosing: [], }; @@ -49,6 +58,18 @@ function comparableSanitizedHtml(html: string): string { .replaceAll(' ', ''); } +/** + * Wraps the given code in a code block. + * + * @param code The code to wrap. + * + * @returns The wrapped code. + */ +export function wrapCode(code: string): string { + const delimiter = '```'; + return `${delimiter}ts\n${code}\n${delimiter}`; +} + /** * Converts a Typescript code block to an HTML string and sanitizes it. * @@ -57,8 +78,7 @@ function comparableSanitizedHtml(html: string): string { * @returns The converted HTML string. */ export function codeToHtml(code: string): string { - const delimiter = '```'; - return mdToHtml(`${delimiter}ts\n${code}\n${delimiter}`); + return mdToHtml(wrapCode(code)); } /** diff --git a/src/distributors/distributor.ts b/src/distributors/distributor.ts new file mode 100644 index 00000000000..0432cbadc18 --- /dev/null +++ b/src/distributors/distributor.ts @@ -0,0 +1,35 @@ +import type { Randomizer } from '../randomizer'; + +/** + * A function that determines the distribution of generated values. + * Values generated by a randomizer are considered uniformly distributed, distributor functions can be used to change this. + * If many results are collected the results form a limited distribution between `0` and `1`. + * So an exponential distributor will values resemble a limited exponential distribution. + * + * Common examples of distributor functions are: + * + * - Uniform distributor: All values have the same likelihood. + * - Normal distributor: Values are more likely to be close to a specific value. + * - Exponential distributor: Values are more likely to be close to 0. + * + * Distributor functions can be used by some faker functions such as `faker.number.int()` and `faker.number.float()`. + * + * Please note that the result from the distributor function is processed further by the function accepting it. + * E.g. a distributor result of `0.5` within a call to `faker.number.int({ min: 10, max: 20 })` will result in `15`. + * + * @param randomizer The randomizer to use for generating values. + * + * @returns Generates a random float between 0 (inclusive) and 1 (exclusive). + * + * @example + * import { Distributor, Randomizer, faker } from '@faker-js/faker'; + * + * const alwaysMin: Distributor = () => 0; + * const uniform: Distributor = (randomizer: Randomizer) => randomizer.next(); + * + * faker.number.int({ min: 2, max: 10, distributor: alwaysMin }); // 2 + * faker.number.int({ min: 0, max: 10, distributor: uniform }); // 5 + * + * @since 9.6.0 + */ +export type Distributor = (randomizer: Randomizer) => number; diff --git a/src/distributors/exponential.ts b/src/distributors/exponential.ts new file mode 100644 index 00000000000..4dfd86ec68b --- /dev/null +++ b/src/distributors/exponential.ts @@ -0,0 +1,122 @@ +import { FakerError } from '../errors/faker-error'; +import type { Distributor } from './distributor'; +import { uniformDistributor } from './uniform'; + +/** + * Creates a new function that generates power-law/exponentially distributed values. + * This function uses `(base ** next() - 1) / (base - 1)` to spread the values. + * + * The following table shows the rough distribution of values generated using `exponentialDistributor({ base: x })`: + * + * | Result | Base 0.1 | Base 0.5 | Base 1 | Base 2 | Base 10 | + * | :-------: | -------: | -------: | -----: | -----: | ------: | + * | 0.0 - 0.1 | 4.1% | 7.4% | 10.0% | 13.8% | 27.8% | + * | 0.1 - 0.2 | 4.5% | 7.8% | 10.0% | 12.5% | 16.9% | + * | 0.2 - 0.3 | 5.0% | 8.2% | 10.0% | 11.5% | 12.1% | + * | 0.3 - 0.4 | 5.7% | 8.7% | 10.0% | 10.7% | 9.4% | + * | 0.4 - 0.5 | 6.6% | 9.3% | 10.0% | 10.0% | 7.8% | + * | 0.5 - 0.6 | 7.8% | 9.9% | 10.0% | 9.3% | 6.6% | + * | 0.6 - 0.7 | 9.4% | 10.7% | 10.0% | 8.8% | 5.7% | + * | 0.7 - 0.8 | 12.1% | 11.5% | 10.0% | 8.2% | 5.0% | + * | 0.8 - 0.9 | 16.9% | 12.6% | 10.0% | 7.8% | 4.5% | + * | 0.9 - 1.0 | 27.9% | 13.8% | 10.0% | 7.5% | 4.1% | + * + * The following table shows the rough distribution of values generated using `exponentialDistributor({ bias: x })`: + * + * | Result | Bias -9 | Bias -1 | Bias 0 | Bias 1 | Bias 9 | + * | :-------: | ------: | ------: | -----: | -----: | -----: | + * | 0.0 - 0.1 | 27.9% | 13.7% | 10.0% | 7.4% | 4.1% | + * | 0.1 - 0.2 | 16.9% | 12.5% | 10.0% | 7.8% | 4.5% | + * | 0.2 - 0.3 | 12.1% | 11.6% | 10.0% | 8.3% | 5.1% | + * | 0.3 - 0.4 | 9.5% | 10.7% | 10.0% | 8.8% | 5.7% | + * | 0.4 - 0.5 | 7.8% | 10.0% | 10.0% | 9.3% | 6.6% | + * | 0.5 - 0.6 | 6.6% | 9.3% | 10.0% | 9.9% | 7.7% | + * | 0.6 - 0.7 | 5.7% | 8.8% | 10.0% | 10.7% | 9.5% | + * | 0.7 - 0.8 | 5.0% | 8.2% | 10.0% | 11.5% | 12.1% | + * | 0.8 - 0.9 | 4.5% | 7.8% | 10.0% | 12.6% | 16.8% | + * | 0.9 - 1.0 | 4.1% | 7.4% | 10.0% | 13.7% | 27.9% | + * + * @param options The options for generating the distributor. + * @param options.base The base of the exponential distribution. Should be greater than 0. Defaults to `2`. + * The higher/more above `1` the `base`, the more likely the number will be closer to the minimum value. + * The lower/closer to zero the `base`, the more likely the number will be closer to the maximum value. + * Values of `1` will generate a uniform distributor. + * Can alternatively be configured using the `bias` option. + * @param options.bias An alternative way to specify the `base`. Also accepts values below zero. Defaults to `-1`. + * The higher/more positive the `bias`, the more likely the number will be closer to the maximum value. + * The lower/more negative the `bias`, the more likely the number will be closer to the minimum value. + * Values of `0` will generate a uniform distributor. + * Can alternatively be configured using the `base` option. + * + * @example + * import { exponentialDistributor, generateMersenne53Randomizer } from '@faker-js/faker'; + * + * const randomizer = generateMersenne53Randomizer(); + * const distributor = exponentialDistributor(); + * distributor(randomizer) // 0.04643770898904198 + * distributor(randomizer) // 0.13436127925491848 + * distributor(randomizer) // 0.4202905589842396 + * distributor(randomizer) // 0.5164955927828387 + * distributor(randomizer) // 0.3476359433171099 + * + * @since 9.6.0 + */ +export function exponentialDistributor( + options?: + | { + /** + * The base of the exponential distribution. Should be greater than 0. + * The higher/more above `1` the `base`, the more likely the number will be closer to the minimum value. + * The lower/closer to zero the `base`, the more likely the number will be closer to the maximum value. + * Values of `1` will generate a uniform distribution. + * Can alternatively be configured using the `bias` option. + * + * @default 2 + */ + base?: number; + } + | { + /** + * An alternative way to specify the `base`. Also accepts values below zero. + * The higher/more positive the `bias`, the more likely the number will be closer to the maximum value. + * The lower/more negative the `bias`, the more likely the number will be closer to the minimum value. + * Values of `0` will generate a uniform distribution. + * Can alternatively be configured using the `base` option. + * + * @default -1 + */ + bias?: number; + } +): Distributor; +/** + * Creates a new function that generates exponentially distributed values. + * This function uses `(base ** next() - 1) / (base - 1)` to spread the values. + * + * @param options The options for generating the distributor. + * @param options.base The base of the exponential distribution. Should be greater than 0. Defaults to `2`. + * The higher/more above `1` the `base`, the more likely the number will be closer to the minimum value. + * The lower/closer to zero the `base`, the more likely the number will be closer to the maximum value. + * Values of `1` will generate a uniform distributor. + * Can alternatively be configured using the `bias` option. + * @param options.bias An alternative way to specify the `base`. Also accepts values below zero. Defaults to `-1`. + * The higher/more positive the `bias`, the more likely the number will be closer to the maximum value. + * The lower/more negative the `bias`, the more likely the number will be closer to the minimum value. + * Values of `0` will generate a uniform distributor. + * Can alternatively be configured using the `base` option. + */ +export function exponentialDistributor( + options: { + base?: number; + bias?: number; + } = {} +): Distributor { + const { bias = -1, base = bias <= 0 ? -bias + 1 : 1 / (bias + 1) } = options; + + if (base === 1) { + return uniformDistributor(); + } else if (base <= 0) { + throw new FakerError('Base should be greater than 0.'); + } + + return ({ next }) => (base ** next() - 1) / (base - 1); +} diff --git a/src/distributors/uniform.ts b/src/distributors/uniform.ts new file mode 100644 index 00000000000..e1796c570ef --- /dev/null +++ b/src/distributors/uniform.ts @@ -0,0 +1,41 @@ +import type { Distributor } from './distributor'; + +/** + * Creates a new function that generates uniformly distributed values. + * The likelihood of each value is the same. + * + * The following table shows the rough distribution of values generated using `uniformDistributor()`: + * + * | Result | Uniform | + * | :-------: | ------: | + * | 0.0 - 0.1 | 10.0% | + * | 0.1 - 0.2 | 10.0% | + * | 0.2 - 0.3 | 10.0% | + * | 0.3 - 0.4 | 10.0% | + * | 0.4 - 0.5 | 10.0% | + * | 0.5 - 0.6 | 10.0% | + * | 0.6 - 0.7 | 10.0% | + * | 0.7 - 0.8 | 10.0% | + * | 0.8 - 0.9 | 10.0% | + * | 0.9 - 1.0 | 10.0% | + * + * @returns A new uniform distributor function. + * + * @example + * import { generateMersenne53Randomizer, uniformDistributor } from '@faker-js/faker'; + * + * const randomizer = generateMersenne53Randomizer(); + * const distributor = uniformDistributor(); + * distributor(randomizer) // 0.9100215692561207 + * distributor(randomizer) // 0.791632947887336 + * distributor(randomizer) // 0.14770035310214324 + * distributor(randomizer) // 0.28282249581185814 + * distributor(randomizer) // 0.017890944117802343 + * + * @since 9.6.0 + */ +export function uniformDistributor(): Distributor { + return UNIFORM_DISTRIBUTOR; +} + +const UNIFORM_DISTRIBUTOR: Distributor = ({ next }) => next(); diff --git a/src/index.ts b/src/index.ts index a8c4ecea739..e0748bc1a78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,9 @@ export type { VehicleDefinition, WordDefinition, } from './definitions'; +export type { Distributor } from './distributors/distributor'; +export { exponentialDistributor } from './distributors/exponential'; +export { uniformDistributor } from './distributors/uniform'; export { FakerError } from './errors/faker-error'; export { Faker } from './faker'; export type { FakerOptions } from './faker'; diff --git a/src/modules/number/index.ts b/src/modules/number/index.ts index f1dce04fb45..513e54737e6 100644 --- a/src/modules/number/index.ts +++ b/src/modules/number/index.ts @@ -1,3 +1,5 @@ +import type { Distributor } from '../../distributors/distributor'; +import { uniformDistributor } from '../../distributors/uniform'; import { FakerError } from '../../errors/faker-error'; import { SimpleModuleBase } from '../../internal/module-base'; @@ -24,6 +26,7 @@ export class NumberModule extends SimpleModuleBase { * @param options.min Lower bound for generated number. Defaults to `0`. * @param options.max Upper bound for generated number. Defaults to `Number.MAX_SAFE_INTEGER`. * @param options.multipleOf Generated number will be a multiple of the given integer. Defaults to `1`. + * @param options.distributor A function to determine the distribution of generated values. Defaults to `uniformDistributor()`. * * @throws When `min` is greater than `max`. * @throws When there are no suitable integers between `min` and `max`. @@ -63,13 +66,24 @@ export class NumberModule extends SimpleModuleBase { * @default 1 */ multipleOf?: number; + /** + * A function to determine the distribution of generated values. + * + * @default uniformDistributor() + */ + distributor?: Distributor; } = {} ): number { if (typeof options === 'number') { options = { max: options }; } - const { min = 0, max = Number.MAX_SAFE_INTEGER, multipleOf = 1 } = options; + const { + min = 0, + max = Number.MAX_SAFE_INTEGER, + multipleOf = 1, + distributor = uniformDistributor(), + } = options; if (!Number.isInteger(multipleOf)) { throw new FakerError(`multipleOf should be an integer.`); @@ -98,7 +112,7 @@ export class NumberModule extends SimpleModuleBase { // @ts-expect-error: access private member field const randomizer = this.faker._randomizer; - const real = randomizer.next(); + const real = distributor(randomizer); const delta = effectiveMax - effectiveMin + 1; // +1 for inclusive max bounds and even distribution return Math.floor(real * delta + effectiveMin) * multipleOf; } @@ -111,6 +125,7 @@ export class NumberModule extends SimpleModuleBase { * @param options.max Upper bound for generated number, exclusive, unless `multipleOf` or `fractionDigits` are passed. Defaults to `1.0`. * @param options.multipleOf The generated number will be a multiple of this parameter. Only one of `multipleOf` or `fractionDigits` should be passed. * @param options.fractionDigits The maximum number of digits to appear after the decimal point, for example `2` will round to 2 decimal points. Only one of `multipleOf` or `fractionDigits` should be passed. + * @param options.distributor A function to determine the distribution of generated values. Defaults to `uniformDistributor()`. * * @throws When `min` is greater than `max`. * @throws When `multipleOf` is negative. @@ -154,6 +169,12 @@ export class NumberModule extends SimpleModuleBase { * The generated number will be a multiple of this parameter. Only one of `multipleOf` or `fractionDigits` should be passed. */ multipleOf?: number; + /** + * A function to determine the distribution of generated values. + * + * @default uniformDistributor() + */ + distributor?: Distributor; } = {} ): number { if (typeof options === 'number') { @@ -168,6 +189,7 @@ export class NumberModule extends SimpleModuleBase { fractionDigits, multipleOf: originalMultipleOf, multipleOf = fractionDigits == null ? undefined : 10 ** -fractionDigits, + distributor = uniformDistributor(), } = options; if (max === min) { @@ -210,13 +232,14 @@ export class NumberModule extends SimpleModuleBase { const int = this.int({ min: min * factor, max: max * factor, + distributor, }); return int / factor; } // @ts-expect-error: access private member field const randomizer = this.faker._randomizer; - const real = randomizer.next(); + const real = distributor(randomizer); return real * (max - min) + min; } diff --git a/test/modules/number.spec.ts b/test/modules/number.spec.ts index 278baadb1df..747061113ac 100644 --- a/test/modules/number.spec.ts +++ b/test/modules/number.spec.ts @@ -1,6 +1,11 @@ import { isHexadecimal, isOctal } from 'validator'; import { describe, expect, it, vi } from 'vitest'; -import { FakerError, SimpleFaker, faker } from '../../src'; +import { + FakerError, + SimpleFaker, + exponentialDistributor, + faker, +} from '../../src'; import { seededTests } from '../support/seeded-runs'; import { MERSENNE_MAX_VALUE } from '../utils/mersenne-test-utils'; import { times } from './../support/times'; @@ -265,6 +270,50 @@ describe('number', () => { new FakerError(`No suitable integer value between 2.1 and 2.9 found.`) ); }); + + it('should generate a number with low base', () => { + const distributor = exponentialDistributor({ base: 0.1 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[faker.number.int({ max: 9, distributor })]++; + } + + expect(results[0]).toBeLessThan(75); + expect(results[9]).toBeGreaterThan(200); + }); + + it('should generate a number with high base', () => { + const distributor = exponentialDistributor({ base: 10 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[faker.number.int({ max: 9, distributor })]++; + } + + expect(results[0]).toBeGreaterThan(200); + expect(results[9]).toBeLessThan(75); + }); + + it('should generate a number with low bias', () => { + const distributor = exponentialDistributor({ bias: -9 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[faker.number.int({ max: 9, distributor })]++; + } + + expect(results[0]).toBeGreaterThan(200); + expect(results[9]).toBeLessThan(75); + }); + + it('should generate a number with high bias', () => { + const distributor = exponentialDistributor({ bias: 9 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[faker.number.int({ max: 9, distributor })]++; + } + + expect(results[0]).toBeLessThan(75); + expect(results[9]).toBeGreaterThan(200); + }); }); describe('float', () => { @@ -407,6 +456,50 @@ describe('number', () => { new FakerError(`Max ${max} should be greater than min ${min}.`) ); }); + + it('should generate a number with low base', () => { + const distributor = exponentialDistributor({ base: 0.1 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[Math.floor(faker.number.float({ max: 10, distributor }))]++; + } + + expect(results[0]).toBeLessThan(75); + expect(results[9]).toBeGreaterThan(200); + }); + + it('should generate a number with high base', () => { + const distributor = exponentialDistributor({ base: 10 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[Math.floor(faker.number.float({ max: 10, distributor }))]++; + } + + expect(results[0]).toBeGreaterThan(200); + expect(results[9]).toBeLessThan(75); + }); + + it('should generate a number with low bias', () => { + const distributor = exponentialDistributor({ bias: -9 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[Math.floor(faker.number.float({ max: 10, distributor }))]++; + } + + expect(results[0]).toBeGreaterThan(200); + expect(results[9]).toBeLessThan(75); + }); + + it('should generate a number with high bias', () => { + const distributor = exponentialDistributor({ bias: 9 }); + const results = Array.from({ length: 10 }, (_, i) => i); + for (let i = 0; i < 1000; i++) { + results[Math.floor(faker.number.float({ max: 10, distributor }))]++; + } + + expect(results[0]).toBeLessThan(75); + expect(results[9]).toBeGreaterThan(200); + }); }); describe('binary', () => { diff --git a/test/scripts/apidocs/__snapshots__/verify-jsdoc-tags.spec.ts.snap b/test/scripts/apidocs/__snapshots__/verify-jsdoc-tags.spec.ts.snap index 94cdab92c00..ea7519a7b56 100644 --- a/test/scripts/apidocs/__snapshots__/verify-jsdoc-tags.spec.ts.snap +++ b/test/scripts/apidocs/__snapshots__/verify-jsdoc-tags.spec.ts.snap @@ -26,6 +26,13 @@ exports[`check docs completeness > all modules and methods are present 1`] = ` "seed", ], ], + [ + "distributors", + [ + "exponentialDistributor", + "uniformDistributor", + ], + ], [ "utils", [