diff --git a/PROJECT_DESCRIPTION.md b/PROJECT_DESCRIPTION.md index fa0d56c..4c0e630 100644 --- a/PROJECT_DESCRIPTION.md +++ b/PROJECT_DESCRIPTION.md @@ -38,7 +38,7 @@ npm run build | Grid pathfinding | `astar`, `dijkstra`, `jumpPointSearch`, `computeFlowField`, `buildNavMesh`, `findNavMeshPath`, `manhattanDistance`, `gridFromString` | `pathfinding/astar.ts`, `pathfinding/dijkstra.ts`, `pathfinding/jumpPointSearch.ts`, `pathfinding/flowField.ts`, `pathfinding/navMesh.ts` | `examples/astar.ts`, `examples/flowField.ts`, `examples/navMesh.ts` | | Procedural textures & terrain | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | -| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | +| Web performance & UI throttling | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts` | | Text & search | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data transforms & diffing | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph traversal | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | diff --git a/README.md b/README.md index e12a0ef..a2df5c9 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ CDN usage: | Procedural generation | `perlin`, `perlin3D`, `simplex2D`, `simplex3D`, `worley`, `worleySample`, `waveFunctionCollapse`, `cellularAutomataCave`, `poissonDiskSampling`, `computeVoronoiDiagram`, `diamondSquare`, `generateLSystem`, `generateBspDungeon`, `generateRecursiveMaze`, `generatePrimMaze`, `generateKruskalMaze`, `generateWilsonMaze`, `generateAldousBroderMaze`, `generateRecursiveDivisionMaze` | `procedural/*.ts` | `examples/simplex.ts`, `examples/worley.ts`, `examples/waveFunctionCollapse.ts`, `examples/cellularAutomata.ts`, `examples/poissonDisk.ts`, `examples/voronoi.ts`, `examples/diamondSquare.ts`, `examples/lSystem.ts`, `examples/dungeonBsp.ts`, `examples/mazeRecursive.ts`, `examples/mazePrim.ts`, `examples/mazeKruskal.ts`, `examples/mazeWilson.ts`, `examples/mazeAldous.ts`, `examples/mazeDivision.ts` | | Spatial queries & collision | `Quadtree`, `aabbCollision`, `aabbIntersection`, `satCollision`, `circleRayIntersection`, `sweptAABB` | `spatial/*.ts` | `examples/sat.ts` | | AI behaviours & crowds | `seek`, `flee`, `arrive`, `pursue`, `wander`, `updateBoids`, `BehaviorTree`, `rvoStep` | `ai/steering.ts`, `ai/boids.ts`, `ai/behaviorTree.ts`, `ai/rvo.ts` | `examples/steering.ts`, `examples/boids.ts`, `examples/rvo.ts` | -| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts` | +| Web performance & UI | `debounce`, `throttle`, `LRUCache`, `memoize`, `deduplicateRequest`, `clearRequestDedup`, `calculateVirtualRange`, `createWeightedAliasSampler` | `util/*.ts` | `examples/requestDedup.ts`, `examples/virtualScroll.ts`, `examples/weightedAlias.ts` | | Search & text | `fuzzySearch`, `fuzzyScore`, `Trie`, `binarySearch`, `levenshteinDistance` | `search/*.ts` | `examples/search.ts` | | Data & diff pipelines | `diff`, `deepClone`, `groupBy`, `diffJson`, `applyJsonDiff` | `data/*.ts` | `examples/jsonDiff.ts` | | Graph algorithms | `graphBFS`, `graphDFS`, `topologicalSort` | `graph/traversal.ts` | `examples/graph.ts` | diff --git a/ROADMAP.md b/ROADMAP.md index 01bee1a..9a86526 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ - [ ] Fixed-timestep game loop utility with interpolation helpers - [ ] Delta-time manager for frame-independent timing - [ ] Object pool helper for reusable entities - - [ ] Weighted random selector (alias method) + - [x] Weighted random selector (alias method) - [ ] Fisher–Yates shuffle implementation - [ ] Bresenham line / raster traversal helpers - Real-time systems: diff --git a/docs/index.d.ts b/docs/index.d.ts index e9f3644..1964afa 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -89,6 +89,7 @@ export const examples: { readonly deduplicateRequest: 'examples/requestDedup.ts'; readonly clearRequestDedup: 'examples/requestDedup.ts'; readonly calculateVirtualRange: 'examples/virtualScroll.ts'; + readonly createWeightedAliasSampler: 'examples/weightedAlias.ts'; }; readonly ai: { readonly seek: 'examples/steering.ts'; @@ -787,6 +788,37 @@ export interface VirtualRange { } export function calculateVirtualRange(options: VirtualScrollOptions): VirtualRange; +/** + * Weighted alias sampler entry. + * Use for: pairing values with weights for alias method. + * Import: util/weightedAlias.ts + */ +export interface WeightedAliasEntry { + value: T; + weight: number; +} + +/** + * Alias sampler descriptor. + * Import: util/weightedAlias.ts + */ +export interface WeightedAliasSampler { + sample(random?: () => number): T; + probabilities: number[]; + aliases: number[]; + values: T[]; +} + +/** + * Creates a weighted sampler using Vose's alias method. + * Use for: constant-time sampling from discrete weighted distributions. + * Performance: O(n) preprocessing, O(1) sampling. + * Import: util/weightedAlias.ts + */ +export function createWeightedAliasSampler( + entries: ReadonlyArray> +): WeightedAliasSampler; + /** * Least recently used cache. * Use for: memoizing responses, data loaders, pagination caches. diff --git a/examples/weightedAlias.ts b/examples/weightedAlias.ts new file mode 100644 index 0000000..9aecb7f --- /dev/null +++ b/examples/weightedAlias.ts @@ -0,0 +1,10 @@ +import { createWeightedAliasSampler } from '../src/index.js'; + +const sampler = createWeightedAliasSampler([ + { value: 'common', weight: 70 }, + { value: 'rare', weight: 25 }, + { value: 'legendary', weight: 5 }, +]); + +const random = () => 0.42; // deterministic example +console.log('Sampled value:', sampler.sample(random)); diff --git a/src/index.ts b/src/index.ts index 65b5e29..5c87705 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ export const examples = { deduplicateRequest: 'examples/requestDedup.ts', clearRequestDedup: 'examples/requestDedup.ts', calculateVirtualRange: 'examples/virtualScroll.ts', + createWeightedAliasSampler: 'examples/weightedAlias.ts', }, ai: { seek: 'examples/steering.ts', @@ -407,6 +408,13 @@ export { deduplicateRequest, clearRequestDedup } from './util/requestDedup.js'; */ export { calculateVirtualRange } from './util/virtualScroll.js'; +/** + * Weighted alias sampler for constant-time discrete sampling. + * + * Example file: examples/weightedAlias.ts + */ +export { createWeightedAliasSampler } from './util/weightedAlias.js'; + /** * Virtual scroll type exports to help define rendering contracts. */ diff --git a/src/util/weightedAlias.ts b/src/util/weightedAlias.ts new file mode 100644 index 0000000..20ca0ac --- /dev/null +++ b/src/util/weightedAlias.ts @@ -0,0 +1,86 @@ +export interface WeightedAliasEntry { + value: T; + weight: number; +} + +export interface WeightedAliasSampler { + sample(random?: () => number): T; + probabilities: number[]; + aliases: number[]; + values: T[]; +} + +/** + * Creates a weighted random sampler using Vose's alias method. + * Useful for: constant-time discrete sampling with many queries. + * Complexity: O(n) preprocessing, O(1) sampling. + */ +export function createWeightedAliasSampler( + entries: ReadonlyArray> +): WeightedAliasSampler { + if (entries.length === 0) { + throw new Error('entries must not be empty'); + } + + let totalWeight = 0; + for (const entry of entries) { + if (entry.weight <= 0) { + throw new Error('weights must be positive numbers'); + } + totalWeight += entry.weight; + } + if (totalWeight <= 0) { + throw new Error('Total weight must be greater than zero.'); + } + + const count = entries.length; + const probabilities = new Array(count); + const aliases = new Array(count).fill(0); + const values = entries.map((entry) => entry.value); + + const scaled = entries.map((entry) => (entry.weight * count) / totalWeight); + const small: number[] = []; + const large: number[] = []; + + scaled.forEach((value, index) => { + if (value < 1) { + small.push(index); + } else { + large.push(index); + } + }); + + while (small.length > 0 && large.length > 0) { + const less = small.pop()!; + const more = large.pop()!; + + probabilities[less] = scaled[less]; + aliases[less] = more; + + scaled[more] = scaled[more] + scaled[less] - 1; + if (scaled[more] < 1) { + small.push(more); + } else { + large.push(more); + } + } + + for (const remaining of [...small, ...large]) { + probabilities[remaining] = 1; + aliases[remaining] = remaining; + } + + function sample(random: () => number = Math.random): T { + const r = random(); + if (r < 0 || r >= 1) { + throw new Error('random function must return value in [0,1).'); + } + const column = Math.floor(r * count); + const probability = probabilities[column]; + const threshold = r * count - column; + const index = threshold < probability ? column : aliases[column]; + return values[index]; + } + + return { sample, probabilities, aliases, values }; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 066e599..40b7c44 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -20,6 +20,7 @@ describe('package entry point', () => { expect(examples.procedural.generateWilsonMaze).toBe('examples/mazeWilson.ts'); expect(examples.procedural.generateAldousBroderMaze).toBe('examples/mazeAldous.ts'); expect(examples.procedural.generateRecursiveDivisionMaze).toBe('examples/mazeDivision.ts'); + expect(examples.performance.createWeightedAliasSampler).toBe('examples/weightedAlias.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); diff --git a/tests/weightedAlias.test.ts b/tests/weightedAlias.test.ts new file mode 100644 index 0000000..b316988 --- /dev/null +++ b/tests/weightedAlias.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { createWeightedAliasSampler } from '../src/index.js'; + +describe('createWeightedAliasSampler', () => { + it('throws on empty entries', () => { + expect(() => createWeightedAliasSampler([])).toThrow(); + }); + + it('samples according to weights with deterministic random', () => { + const sampler = createWeightedAliasSampler([ + { value: 'a', weight: 1 }, + { value: 'b', weight: 3 }, + ]); + + const rngValues = [0.1, 0.6, 0.8, 0.2, 0.9]; + let index = 0; + const random = () => { + const value = rngValues[index % rngValues.length]; + index += 1; + return value ?? 0; + }; + + const samples = Array.from({ length: rngValues.length }, () => sampler.sample(random)); + expect(samples.filter((value) => value === 'b').length).toBeGreaterThan( + samples.filter((value) => value === 'a').length + ); + }); +});