From 8ac49a89728db53c5e12f1f116d8d1624fd3e393 Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Fri, 26 Jun 2026 23:21:44 -0700 Subject: [PATCH 01/10] Modernize and clean up the package (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup, modernization, and safety pass over @yipe/dice. All 1085 existing tests still pass (1091 with new coverage); typecheck, lint, build, and npm pack all green. No change to probability math / numeric output. Tier 1 — safe cleanup: - Remove stale package-lock.json (project is Yarn 4) and unused devDeps (ts-node, tsconfig-paths). - Remove console.error calls from the parser so the library no longer writes to a consumer's console (defensive behavior preserved). - Fix typos and stale comments; correct invalid 'miss' OutcomeType references in DiceQuery JSDoc. Tier 2 — internal refactor (no behavior change): - Drop impossible "[number, number]" iterator branches and the matching `as any` casts; PMF always iterates [number, Bin]. - Deduplicate Bin clone/scale logic in PMF via cloneBin/scaleBin helpers. normalize() keeps direct division for bit-identical results. - Remove dead commented-out code (collapseIdentical). - Tighten internal `any`: typed Dice.privateData, MaxOfNode, and a typed view for the protected lastConfig getter. Tier 3 — API changes (minor breaking, see CHANGELOG): - Add DiceParseError (exported); parse() now throws it with .expression and .cause. Messages unchanged, so existing catches keep working. - PMF.toJSON() now returns a plain object (no double-encoding via JSON.stringify); add PMF.toJSONString() for the old string form. - Tighten DiceQuery.firstSuccessSplit() to OutcomeType | OutcomeType[]. - Add DiceQuery.stdev() alias to match PMF.stdev(). - Bump version 0.2.23 -> 0.3.0; add CHANGELOG.md and README error-handling example. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 38 + README.md | 17 + package-lock.json | 2792 ------------------------------- package.json | 4 +- src/builder/ac.test.ts | 4 +- src/builder/ast.ts | 7 +- src/builder/attack.ts | 12 +- src/builder/dc.ts | 4 +- src/builder/roll.ts | 12 +- src/builder/save.ts | 4 +- src/common/errors.ts | 34 + src/index.ts | 1 + src/parser/dice.ts | 23 +- src/parser/parser.ts | 19 +- src/pmf/mixture.ts | 9 +- src/pmf/pmf.ts | 178 +- src/pmf/query.ts | 22 +- tests/api-modernization.test.ts | 58 + tests/mixture.test.ts | 23 +- tests/pmf-advanced.test.ts | 6 +- yarn.lock | 178 +- 21 files changed, 301 insertions(+), 3144 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 package-lock.json create mode 100644 src/common/errors.ts create mode 100644 tests/api-modernization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8366930 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] + +### Breaking + +- **`PMF.toJSON()` now returns a plain object** (`{ bins, normalized, identifier }`) + instead of a JSON string, following the standard `toJSON` contract. This means + `JSON.stringify(pmf)` no longer double-encodes. If you relied on the old string + return, call the new `PMF.toJSONString()` instead. +- **`DiceQuery.firstSuccessSplit()` is typed as `OutcomeType | OutcomeType[]`** + (previously `string | string[]`). Only affects callers passing arbitrary strings; + valid outcome labels are unchanged. + +### Added + +- **`DiceParseError`** — `parse()` now throws this typed error (a subclass of + `Error`) instead of a plain `Error`. Existing `try/catch` and message checks keep + working; you can now narrow with `instanceof DiceParseError` and read + `error.expression` / `error.cause`. +- **`PMF.toJSONString()`** — returns the JSON string form (the previous + `toJSON()` behavior). +- **`DiceQuery.stdev()`** — alias of `stddev()`, matching `PMF.stdev()`. + +### Changed + +- Removed the stale `package-lock.json` (the project uses Yarn 4) and dropped the + unused `ts-node` / `tsconfig-paths` dev dependencies. +- Removed `console.error` calls from the parser so the library no longer writes to + a consumer's console. +- Internal refactors with no behavioral change: deduplicated `Bin` clone/scale + logic in `PMF`, removed dead code and impossible iterator branches, and tightened + internal `any` usage. diff --git a/README.md b/README.md index 6eeae2e..7293e44 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,23 @@ const query = new DiceQuery(pmf); console.log("DPR:", query.mean()); ``` +### Error Handling + +`parse()` throws a `DiceParseError` (a subclass of `Error`) for invalid input. +Narrow with `instanceof` and inspect the offending `expression`: + +```ts +import { parse, DiceParseError } from "@yipe/dice"; + +try { + parse("d6@3"); +} catch (err) { + if (err instanceof DiceParseError) { + console.warn(`Bad dice expression: ${err.expression}`); + } +} +``` + ### Sneak Attack (Conditional Damage) Conditional damage ("once-per-turn damage riders") like Sneak Attack can be modeled easily: diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d795d09..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2792 +0,0 @@ -{ - "name": "dice", - "version": "0.2.23", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "dice", - "version": "0.2.23", - "devDependencies": { - "@types/node": "^24.3.1", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsup": "^8.5.0", - "tsx": "^4.20.5", - "typescript": "^5.9.2", - "vitest": "^3.2.4" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", - "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", - "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", - "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", - "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", - "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", - "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", - "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", - "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", - "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", - "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", - "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", - "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", - "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", - "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", - "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", - "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", - "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", - "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", - "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", - "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", - "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", - "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.10.0" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/rollup": { - "version": "4.50.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", - "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.50.1", - "@rollup/rollup-android-arm64": "4.50.1", - "@rollup/rollup-darwin-arm64": "4.50.1", - "@rollup/rollup-darwin-x64": "4.50.1", - "@rollup/rollup-freebsd-arm64": "4.50.1", - "@rollup/rollup-freebsd-x64": "4.50.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", - "@rollup/rollup-linux-arm-musleabihf": "4.50.1", - "@rollup/rollup-linux-arm64-gnu": "4.50.1", - "@rollup/rollup-linux-arm64-musl": "4.50.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", - "@rollup/rollup-linux-ppc64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-gnu": "4.50.1", - "@rollup/rollup-linux-riscv64-musl": "4.50.1", - "@rollup/rollup-linux-s390x-gnu": "4.50.1", - "@rollup/rollup-linux-x64-gnu": "4.50.1", - "@rollup/rollup-linux-x64-musl": "4.50.1", - "@rollup/rollup-openharmony-arm64": "4.50.1", - "@rollup/rollup-win32-arm64-msvc": "4.50.1", - "@rollup/rollup-win32-ia32-msvc": "4.50.1", - "@rollup/rollup-win32-x64-msvc": "4.50.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-literal": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", - "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", - "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tsup": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", - "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.25.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsx": { - "version": "4.20.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", - "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", - "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - } - } -} diff --git a/package.json b/package.json index 2d26ffe..0e9f400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yipe/dice", - "version": "0.2.23", + "version": "0.3.0", "description": "A high-performance dice probability engine for D&D 5e DPR calculations. Powers dprcalc.com.", "keywords": [ "dnd", @@ -84,8 +84,6 @@ "@typescript-eslint/parser": "^8.61.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^9.0.0", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/src/builder/ac.test.ts b/src/builder/ac.test.ts index c035d5e..b050368 100644 --- a/src/builder/ac.test.ts +++ b/src/builder/ac.test.ts @@ -102,8 +102,8 @@ describe("AttackRollBuilder", () => { const copy = original.copy(); expect(copy.toExpression()).toBe(original.toExpression()); - const a = JSON.parse(copy.toPMF().toJSON()); - const b = JSON.parse(original.toPMF().toJSON()); + const a = JSON.parse(copy.toPMF().toJSONString()); + const b = JSON.parse(original.toPMF().toJSONString()); delete a.identifier; delete b.identifier; expect(a).toEqual(b); diff --git a/src/builder/ast.ts b/src/builder/ast.ts index 8de90f0..4feb82b 100644 --- a/src/builder/ast.ts +++ b/src/builder/ast.ts @@ -8,6 +8,7 @@ import type { DieNode, ExpressionNode, KeepNode, + MaxOfNode, SumNode, } from "./nodes"; import type { RollBuilder } from "./roll"; @@ -80,7 +81,11 @@ export function astFromRollConfigs( if (trials === 1) { node = perTrial; } else { - node = { type: "maxOf", count: trials, child: perTrial } as any; + node = { + type: "maxOf", + count: trials, + child: perTrial, + } as MaxOfNode; } } else if (trials === baseCount) { // Classic pool: keep K of N faces from N iid dice diff --git a/src/builder/attack.ts b/src/builder/attack.ts index 7c00a2c..4ac9567 100644 --- a/src/builder/attack.ts +++ b/src/builder/attack.ts @@ -140,8 +140,8 @@ export class AttackBuilder implements CheckBuilder { let pcrit = 0; let pmiss = 0; - for (const [r, rec] of d20 as any as Iterable<[number, any]>) { - const pr = typeof rec === "number" ? rec : rec.p; + for (const [r, bin] of d20) { + const pr = bin.p; if (pr <= 0) continue; // Natural 1 always misses @@ -165,8 +165,8 @@ export class AttackBuilder implements CheckBuilder { if (check instanceof AlwaysHitBuilder) { // Preserve rollType for crit odds let pCrit = 0; - for (const [r, rec] of d20 as any as Iterable<[number, any]>) { - const pr = typeof rec === "number" ? rec : rec.p; + for (const [r, bin] of d20) { + const pr = bin.p; if (pr <= 0) continue; if (r >= critThreshold) pCrit += pr; } @@ -188,8 +188,8 @@ export class AttackBuilder implements CheckBuilder { let phit = 0; let pmiss = 0; - for (const [r, rec] of d20 as any as Iterable<[number, any]>) { - const pr = typeof rec === "number" ? rec : rec.p; + for (const [r, bin] of d20) { + const pr = bin.p; if (pr <= 0) continue; // Handle auto-miss diff --git a/src/builder/dc.ts b/src/builder/dc.ts index aa762be..ddf22ca 100644 --- a/src/builder/dc.ts +++ b/src/builder/dc.ts @@ -79,8 +79,8 @@ export class DCBuilder extends RollBuilder { : PMF.delta(0, eps); let psuccess = 0; - for (const [r, rec] of d20 as any as Iterable<[number, any]>) { - const pr = typeof rec === "number" ? rec : rec.p; + for (const [r, bin] of d20) { + const pr = bin.p; if (pr <= 0) continue; const need = saveDC - staticMod - r; psuccess += pr * bonusPMF.tailProbGE(need); diff --git a/src/builder/roll.ts b/src/builder/roll.ts index f37ae48..170a495 100644 --- a/src/builder/roll.ts +++ b/src/builder/roll.ts @@ -752,8 +752,10 @@ export class HalfRollBuilder extends RollBuilder { // Which is fine. // The only issue is if we want it to return HalfRollBuilder, but it doesn't seem designed for that. - get lastConfig() { - return (this.innerRoll as any).lastConfig; + override get lastConfig(): RollConfig { + // `lastConfig` is protected on the base class; reach it on the wrapped + // instance via a typed view rather than `any`. + return (this.innerRoll as unknown as { lastConfig: RollConfig }).lastConfig; } getSubRollConfigs(): readonly RollConfig[] { @@ -795,8 +797,10 @@ export class MaxOfRollBuilder extends RollBuilder { return this.innerRoll.hasHiddenState(); } - get lastConfig() { - return (this.innerRoll as any).lastConfig; + override get lastConfig(): RollConfig { + // `lastConfig` is protected on the base class; reach it on the wrapped + // instance via a typed view rather than `any`. + return (this.innerRoll as unknown as { lastConfig: RollConfig }).lastConfig; } getSubRollConfigs(): readonly RollConfig[] { diff --git a/src/builder/save.ts b/src/builder/save.ts index b32c6e7..d15591f 100644 --- a/src/builder/save.ts +++ b/src/builder/save.ts @@ -90,8 +90,8 @@ function resolveProbabilities(check: DCBuilder): { const die = d20RollPMF(d20Type, baseReroll > 0); const faceP = new Map(); - for (const [r, rec] of die as any as Iterable<[number, any]>) { - const pr = typeof rec === "number" ? rec : rec.p; + for (const [r, bin] of die) { + const pr = bin.p; if (pr > 0) faceP.set(r, pr); } diff --git a/src/common/errors.ts b/src/common/errors.ts new file mode 100644 index 0000000..8e1b61b --- /dev/null +++ b/src/common/errors.ts @@ -0,0 +1,34 @@ +/** + * Error thrown when a dice expression cannot be parsed. + * + * Extends the built-in {@link Error}, so existing `catch (e)` / message checks + * continue to work, while callers can now narrow with `instanceof DiceParseError`. + * + * @example + * try { + * parse("d6@3"); + * } catch (e) { + * if (e instanceof DiceParseError) { + * // e.expression === "d6@3" + * } + * } + */ +export class DiceParseError extends Error { + /** The original expression that failed to parse, when available. */ + readonly expression?: string; + + /** The underlying error that triggered this one, when available. */ + readonly cause?: unknown; + + constructor( + message: string, + options?: { expression?: string; cause?: unknown } + ) { + super(message); + this.name = "DiceParseError"; + this.expression = options?.expression; + this.cause = options?.cause; + // Restore the prototype chain for reliable `instanceof` across targets. + Object.setPrototypeOf(this, DiceParseError.prototype); + } +} diff --git a/src/index.ts b/src/index.ts index ad5c73c..f9bfdd1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./common/errors"; export * from "./common/lru-cache"; export * from "./common/types"; export * from "./parser/parser"; diff --git a/src/parser/dice.ts b/src/parser/dice.ts index e361c30..0f735b2 100644 --- a/src/parser/dice.ts +++ b/src/parser/dice.ts @@ -9,12 +9,22 @@ import type { import { EPS } from "../common/types"; import { PMF } from "../pmf/pmf"; +/** Internal bookkeeping attached to a {@link Dice} during parsing. */ +export interface DicePrivateData { + /** Marks a DC (saving-throw) check so outcomes are attributed correctly. */ + isDCCheck?: boolean; + /** The "other" distribution recorded by {@link Dice.combine}. */ + except?: Dice | Record; + /** Keep-highest/lowest selector applied when a die is multiplied out. */ + keep?: (values: number[]) => number; +} + /** * @internal */ export class Dice { private readonly faces: DamageDistribution = {}; - public privateData: Record = {}; + public privateData: DicePrivateData = {}; private outcomeData: Record = {} as Record< OutcomeType, DamageDistribution @@ -114,9 +124,9 @@ export class Dice { hitCount = 0; } - //WARNING: This is checking for the bug in old inclusion-exclusion logic. + // Defensive clamp: guards against negative hit counts from older + // inclusion-exclusion logic. Should never trigger in practice. if (hitCount < 0) { - console.error("hitCount is <=0?", face, totalCount, hitCount); hitCount = 0; } hitValues[numFace] = hitCount; @@ -179,7 +189,7 @@ export class Dice { return result; } - // PUBLIC FUNTIONS + // PUBLIC FUNCTIONS getFaceEntries(): [number, number][] { return Object.entries(this.faces).map(([k, v]) => [Number(k), v]); @@ -564,10 +574,9 @@ export class Dice { map.set(face, bin); } + // Fall back to a sentinel identifier if none was assigned. This indicates + // a Dice constructed outside the normal parse/build flow. const identifier = this.identifier || "ERROR"; - if (identifier === "ERROR") { - console.error("Dice identifier is undefined", this); - } // Normalize, drop zeros again if any snuck in, then compaction and pruning return new PMF(map, numEpsilon, true, identifier).compact(numEpsilon, true); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index a3c3e48..b8f4c1b 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -1,3 +1,4 @@ +import { DiceParseError } from "../common/errors"; import { LRUCache } from "../common/lru-cache"; import type { OutcomeType } from "../common/types"; import type { PMF } from "../pmf/pmf"; @@ -48,21 +49,23 @@ export function parse(expression: string, n: number = 0): PMF { const chars = [...cleaned]; - let result: Dice | undefined = undefined; + let result: Dice; try { result = parseExpression(chars, n); } catch (error) { - throw new Error(`Cannot parse dice expression [${expression}]: ${error}`); + throw new DiceParseError( + `Cannot parse dice expression [${expression}]: ${error}`, + { expression, cause: error } + ); } - try { - (result as any).privateData = (result as any).privateData || {}; - result.identifier = cleaned; - } catch {} + result.privateData = result.privateData || {}; + result.identifier = cleaned; if (chars.length > 0) { - throw new Error( - `Unexpected token: '${chars[0]}' from expression: '${expression}'` + throw new DiceParseError( + `Unexpected token: '${chars[0]}' from expression: '${expression}'`, + { expression } ); } diff --git a/src/pmf/mixture.ts b/src/pmf/mixture.ts index 79b62fd..379abd2 100644 --- a/src/pmf/mixture.ts +++ b/src/pmf/mixture.ts @@ -2,8 +2,6 @@ import type { Bin } from "../common/types"; import { EPS } from "../common/types"; import { PMF } from "./pmf"; -type BinEntries = Iterable<[number, Bin]>; - /** A labeled mixture builder that preserves provenance in Bin.count. */ export class Mixture { private readonly totals = new Map(); // raw mass per outcome (pre-normalization) @@ -39,10 +37,9 @@ export class Mixture { add(label: L, pmf: PMF, weight = 1): this { if (!Number.isFinite(weight) || weight <= 0) return this; - // Stream probabilities. Works whether PMF iterates as [v, p] or [v, Bin]. - for (const [v, binOrNumber] of pmf as any as BinEntries) { - const isNumber = typeof binOrNumber === "number"; - const p = isNumber ? binOrNumber : binOrNumber?.p ?? 0; + // Stream probabilities from each [value, Bin] pair. + for (const [v, bin] of pmf) { + const p = bin.p; if (p <= 0) continue; const add = weight * p; diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index 03989dd..03d4d49 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -92,8 +92,6 @@ export class PMF { .addScaled(failurePMF, q) .addScaled(successPMF, p); - // Optional hygiene: drop exact zeros - // return out.compact(); return out; } @@ -144,8 +142,8 @@ export class PMF { * @param fallback PMF to apply when this PMF is *not* selected. * @returns A new PMF representing the weighted mixture of this PMF and the fallback. */ - gate(p: number, zero: PMF) { - return PMF.branch(this, zero, p); + gate(p: number, fallback: PMF) { + return PMF.branch(this, fallback, p); } /** @@ -404,7 +402,7 @@ export class PMF { */ replicate(n: number): PMF[] { if (!Number.isInteger(n) || n <= 0) { - throw new Error("combineN(n): n must be a positive integer"); + throw new Error("replicate(n): n must be a positive integer"); } if (n === 1) return [this]; return Array.from({ length: n }, () => this); @@ -439,11 +437,11 @@ export class PMF { const normalizationFactor = this.mass(); if (normalizationFactor === 0) return this; + // Note: this divides by normalizationFactor rather than multiplying by its + // reciprocal, to keep results bit-identical to direct division. const normalizedMap = new Map(); for (const [damageValue, probabilityBin] of this.map) { - const normalizedProbability = probabilityBin.p / normalizationFactor; const normalizedCount: OutcomeLabelMap = {}; - for (const labelKey in probabilityBin.count) { normalizedCount[labelKey] = (probabilityBin.count[labelKey] as number) / normalizationFactor; @@ -459,7 +457,7 @@ export class PMF { } normalizedMap.set(damageValue, { - p: normalizedProbability, + p: probabilityBin.p / normalizationFactor, count: normalizedCount, attr: normalizedAttributes, }); @@ -582,6 +580,33 @@ export class PMF { return this._stdev; } + /** Deep-copies a Bin, cloning its count and (optional) attr maps. */ + private static cloneBin(bin: Bin): Bin { + return { + p: bin.p, + count: { ...bin.count }, + attr: bin.attr ? { ...bin.attr } : undefined, + }; + } + + /** Returns a new Bin with p, count, and attr all multiplied by `factor`. */ + private static scaleBin(bin: Bin, factor: number): Bin { + const count: OutcomeLabelMap = {}; + for (const k in bin.count) { + count[k] = (bin.count[k] as number) * factor; + } + + let attr: OutcomeLabelMap | undefined; + if (bin.attr) { + attr = {}; + for (const k in bin.attr) { + attr[k] = (bin.attr[k] as number) * factor; + } + } + + return { p: bin.p * factor, count, attr }; + } + private static mergeInto( destinationMap: Map, damageValue: number, @@ -589,11 +614,7 @@ export class PMF { ) { const existingBin = destinationMap.get(damageValue); if (!existingBin) { - destinationMap.set(damageValue, { - p: binToAdd.p, - count: { ...binToAdd.count }, - attr: binToAdd.attr ? { ...binToAdd.attr } : undefined, - }); + destinationMap.set(damageValue, PMF.cloneBin(binToAdd)); return; } @@ -634,33 +655,15 @@ export class PMF { const resultMap = new Map(); for (const [dmg, bin] of this.map) { - resultMap.set(dmg, { - p: bin.p, - count: { ...bin.count }, - attr: bin.attr ? { ...bin.attr } : undefined, - }); + resultMap.set(dmg, PMF.cloneBin(bin)); } for (const [damageValue, probabilityBin] of branch.map) { - const scaledCount: OutcomeLabelMap = {}; - for (const k in probabilityBin.count) { - scaledCount[k] = probability * (probabilityBin.count[k] as number); - } - - let scaledAttributes: OutcomeLabelMap | undefined; - if (probabilityBin.attr) { - scaledAttributes = {}; - for (const k in probabilityBin.attr) { - scaledAttributes[k] = - probability * (probabilityBin.attr[k] as number); - } - } - - PMF.mergeInto(resultMap, damageValue, { - p: probability * probabilityBin.p, - count: scaledCount, - attr: scaledAttributes, - }); + PMF.mergeInto( + resultMap, + damageValue, + PMF.scaleBin(probabilityBin, probability) + ); } return new PMF( @@ -676,26 +679,7 @@ export class PMF { const scaledMap = new Map(); for (const [damageValue, probabilityBin] of this.map) { - const scaledCount: OutcomeLabelMap = {}; - for (const labelKey in probabilityBin.count) { - scaledCount[labelKey] = - (probabilityBin.count[labelKey] as number) * factor; - } - - let scaledAttributes: OutcomeLabelMap | undefined; - if (probabilityBin.attr) { - scaledAttributes = {}; - for (const labelKey in probabilityBin.attr) { - scaledAttributes[labelKey] = - (probabilityBin.attr[labelKey] as number) * factor; - } - } - - scaledMap.set(damageValue, { - p: probabilityBin.p * factor, - count: scaledCount, - attr: scaledAttributes, - }); + scaledMap.set(damageValue, PMF.scaleBin(probabilityBin, factor)); } return new PMF( scaledMap, @@ -709,11 +693,11 @@ export class PMF { const transformedMap = new Map(); for (const [originalDamage, probabilityBin] of this.map) { const transformedDamage = damageTransformFunction(originalDamage); - PMF.mergeInto(transformedMap, transformedDamage, { - p: probabilityBin.p, - count: { ...probabilityBin.count }, - attr: probabilityBin.attr ? { ...probabilityBin.attr } : undefined, - }); + PMF.mergeInto( + transformedMap, + transformedDamage, + PMF.cloneBin(probabilityBin) + ); } return new PMF( transformedMap, @@ -823,31 +807,6 @@ export class PMF { return this.convolve(other, eps, true); } - // Collapse repeated identical PMFs using power() and return a sorted list - // Temporarily disabled for now. This was used for a performance optimization, but can lose data provenance. - // private static collapseIdentical(pmfList: PMF[], eps: number): PMF[] { - // const grouped = new Map(); - // for (const pmf of pmfList) { - // const id = pmf.identifier; - // const g = grouped.get(id); - // if (g) g.count++; - // else grouped.set(id, { pmf, count: 1 }); - // } - - // if (grouped.size >= pmfList.length) return pmfList; - - // const collapsed: PMF[] = []; - // for (const { pmf, count } of grouped.values()) { - // collapsed.push(count > 1 ? pmf.power(count, eps) : pmf); - // } - - // // Stable order for better cache locality - // collapsed.sort((a, b) => - // a.identifier < b.identifier ? -1 : a.identifier > b.identifier ? 1 : 0 - // ); - // return collapsed; - // } - // Reduce a list of PMFs by left-folding convolve() with the given eps private static reduceConvolveLeft(pmfList: PMF[], eps: number): PMF { let result = pmfList[0]; @@ -871,21 +830,33 @@ export class PMF { if (pmfList.length === 0) return PMF.empty(eps); if (pmfList.length === 1) return pmfList[0]; - // Optimization: Group identical PMFs and use power() to collapse repeats - // If nothing collapses, this returns the same array reference. - // const collapsed = PMF.collapseIdentical(pmfList, eps); - - // Optimization: Linear combination with automatic intermediate caching - // Whether collapsed or not, reduce via left-folded convolved() + // Linear combination with automatic intermediate caching: each prefix + // (A+B, (A+B)+C, ...) is a stable cache key, maximizing reuse. return PMF.reduceConvolveLeft(pmfList, eps); } - toJSON() { - return JSON.stringify({ + /** + * Returns a plain, JSON-serializable representation of this PMF. + * + * Follows the standard `toJSON` contract, so `JSON.stringify(pmf)` produces + * the expected output (no double-encoding). Use {@link PMF.fromJSON} to + * reconstruct, or {@link PMF.toJSONString} if you need the string directly. + */ + toJSON(): { + bins: Array<[number, Bin]>; + normalized: boolean; + identifier: string; + } { + return { bins: [...this.map.entries()], normalized: this.normalized, identifier: this.identifier, - }); + }; + } + + /** Serializes this PMF to a JSON string (equivalent to `JSON.stringify(pmf)`). */ + toJSONString(): string { + return JSON.stringify(this); } static fromJSON(jsonData: { @@ -977,7 +948,6 @@ export class PMF { return new PMF(prunedMap, epsRel, false, `prune(${this.identifier})`); } - /** NEW - REVIEW IF THESE ARE USEFUL OR DUPLCIATIVE? */ /** Probability mass at exactly x. */ pAt(x: number): number { return this.map.get(x)?.p ?? 0; @@ -1075,9 +1045,8 @@ export class PMF { tailProbGE(t: number): number { let s = 0; - for (const [x, rec] of this) { - const p = typeof rec === "number" ? rec : rec.p; - if (p > 0 && x >= t) s += p; + for (const [x, bin] of this) { + if (bin.p > 0 && x >= t) s += bin.p; } return s; } @@ -1195,15 +1164,14 @@ export class PMF { const probs = new Map(); const counts = new Map>(); - for (const [v, rec] of this) { - if (Math.abs(rec.p) < eps) continue; + for (const [v, bin] of this) { + if (Math.abs(bin.p) < eps) continue; const u = round(f(v)); - probs.set(u, (probs.get(u) ?? 0) + rec.p); + probs.set(u, (probs.get(u) ?? 0) + bin.p); if (preserveCounts) { // Merge counts if present - const rec = this.map.get(v); - const src = typeof rec === "number" ? undefined : rec?.count; + const src = bin.count; if (src) { const dest = counts.get(u) ?? {}; for (const k in src) { diff --git a/src/pmf/query.ts b/src/pmf/query.ts index 71e7878..6ef4e6c 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -114,6 +114,11 @@ export class DiceQuery { return Math.sqrt(this.variance()); } + /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */ + stdev(): number { + return this.stddev(); + } + /** * Returns the Cumulative Distribution Function. */ @@ -323,7 +328,7 @@ export class DiceQuery { * Array examples: * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds - * - probExactlyK(['miss', 'missNone'], 0) = probability no attacks miss + * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss * * Use cases: * - "What's the chance exactly one of my attacks hits?" @@ -368,7 +373,7 @@ export class DiceQuery { * Single label examples: * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1) * - probAtMostK('crit', 0) = probability no attacks crit - * - probAtMostK('miss', 2) = probability at most 2 attacks miss + * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss * * Array examples: * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds @@ -444,7 +449,7 @@ export class DiceQuery { * * Array examples: * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds - * - damageStatsFrom(['miss', 'missNone']) = damage range when at least one attack misses + * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses * * Tactical Use Cases: * - "Given that I don't completely whiff (99% of turns), what damage should I expect?" @@ -1540,8 +1545,8 @@ export class DiceQuery { * Returns tuple: [pFirstNonSubset, pFirstSubset, pAnySuccess, pNone] */ public firstSuccessSplit( - successOutcome: string | string[], - subsetOutcome: string | string[], + successOutcome: OutcomeType | OutcomeType[], + subsetOutcome: OutcomeType | OutcomeType[], eps = EPS ): readonly [pSuccess: number, pSubset: number, pAny: number, pNone: number] { const pmfs = this.singles; @@ -1549,15 +1554,16 @@ export class DiceQuery { throw new Error("firstSuccessSplitFromPMFs: pmfs must be non-empty"); } - const toArr = (x: string | string[]) => (Array.isArray(x) ? x : [x]); + const toArr = (x: OutcomeType | OutcomeType[]): OutcomeType[] => + Array.isArray(x) ? x : [x]; const clamp01 = (x: number) => Math.max(0, Math.min(1, x)); const tol = Math.max(eps, 8 * Number.EPSILON); // Per-event probabilities from each PMF via DiceQuery([pmf]) const per = pmfs.map((pmf) => { const dq = new DiceQuery([pmf]); - const pS = dq.probAtLeastOne(toArr(successOutcome) as any); - const pB = dq.probAtLeastOne(toArr(subsetOutcome) as any); + const pS = dq.probAtLeastOne(toArr(successOutcome)); + const pB = dq.probAtLeastOne(toArr(subsetOutcome)); if (pB - pS > eps) { throw new Error( "firstSuccessSplitFromPMFs: P(subset) > P(success) for an event. Ensure subset ⊆ success." diff --git a/tests/api-modernization.test.ts b/tests/api-modernization.test.ts new file mode 100644 index 0000000..e7f9499 --- /dev/null +++ b/tests/api-modernization.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { DiceParseError, parse, PMF } from "../src/index"; + +describe("DiceParseError", () => { + it("is thrown for unexpected tokens and is an Error", () => { + let caught: unknown; + try { + parse("d6@3"); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(DiceParseError); + expect(caught).toBeInstanceOf(Error); + expect((caught as DiceParseError).message).toContain("Unexpected token"); + expect((caught as DiceParseError).expression).toBe("d6@3"); + }); + + it("wraps parse failures with the original cause", () => { + let caught: unknown; + try { + parse("d6+"); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(DiceParseError); + expect((caught as DiceParseError).cause).toBeDefined(); + }); +}); + +describe("PMF JSON serialization", () => { + it("toJSON returns a plain object (no double-encoding)", () => { + const pmf = PMF.delta(3); + const json = pmf.toJSON(); + expect(typeof json).toBe("object"); + expect(Array.isArray(json.bins)).toBe(true); + }); + + it("round-trips through JSON.stringify + fromJSON", () => { + const pmf = parse("2d6 + 3"); + const restored = PMF.fromJSON(JSON.parse(JSON.stringify(pmf))); + expect(restored.mean()).toBeCloseTo(pmf.mean(), 12); + expect(restored.support()).toEqual(pmf.support()); + }); + + it("toJSONString preserves the legacy string output", () => { + const pmf = PMF.delta(5); + const str = pmf.toJSONString(); + expect(typeof str).toBe("string"); + expect(JSON.parse(str)).toEqual(pmf.toJSON()); + }); +}); + +describe("DiceQuery.stdev alias", () => { + it("matches stddev()", () => { + const q = parse("2d6 + 3").query(); + expect(q.stdev()).toBe(q.stddev()); + }); +}); diff --git a/tests/mixture.test.ts b/tests/mixture.test.ts index 3bf87af..861bf21 100644 --- a/tests/mixture.test.ts +++ b/tests/mixture.test.ts @@ -256,34 +256,17 @@ describe("Mixture Helpers", () => { expect(pmfs).toEqual({}); }); - it("getProb (via add) should correctly extract probability from Bin or number", () => { + it("add should correctly extract probability from each [value, Bin] pair", () => { const mix = new Mixture(); - // Create a mock PMF-like object that iterates with Bin objects - const pmfWithBins = { - [Symbol.iterator]: function* () { - yield [1, { p: 0.25, count: {} }]; - yield [2, { p: 0.75, count: {} }]; - }, - }; - - // @ts-expect-error - using a mock PMF - mix.add("a", pmfWithBins, 1); + mix.add("a", PMF.fromMap(new Map([[1, 0.25], [2, 0.75]])), 1); const pmf1 = mix.buildPMF(); expect(pmf1.pAt(1)).toBeCloseTo(0.25, 12); expect(pmf1.pAt(2)).toBeCloseTo(0.75, 12); mix.clear(); - // Create a mock PMF-like object that iterates with raw numbers (less common) - const pmfWithNumbers = { - [Symbol.iterator]: function* () { - yield [5, 0.4]; - yield [6, 0.6]; - }, - }; - // @ts-expect-error - using a mock PMF - mix.add("b", pmfWithNumbers, 1); + mix.add("b", PMF.fromMap(new Map([[5, 0.4], [6, 0.6]])), 1); const pmf2 = mix.buildPMF(); expect(pmf2.pAt(5)).toBeCloseTo(0.4, 12); expect(pmf2.pAt(6)).toBeCloseTo(0.6, 12); diff --git a/tests/pmf-advanced.test.ts b/tests/pmf-advanced.test.ts index d5eb236..1647f8f 100644 --- a/tests/pmf-advanced.test.ts +++ b/tests/pmf-advanced.test.ts @@ -118,13 +118,13 @@ describe("PMF Advanced Operations", () => { it("should throw for non-positive integers", () => { expect(() => d6.replicate(0)).toThrow( - "combineN(n): n must be a positive integer" + "replicate(n): n must be a positive integer" ); expect(() => d6.replicate(-1)).toThrow( - "combineN(n): n must be a positive integer" + "replicate(n): n must be a positive integer" ); expect(() => d6.replicate(2.5)).toThrow( - "combineN(n): n must be a positive integer" + "replicate(n): n must be a positive integer" ); }); diff --git a/yarn.lock b/yarn.lock index 48f5eaf..08e48a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,15 +47,6 @@ __metadata: languageName: node linkType: hard -"@cspotcode/source-map-support@npm:^0.8.0": - version: 0.8.1 - resolution: "@cspotcode/source-map-support@npm:0.8.1" - dependencies: - "@jridgewell/trace-mapping": "npm:0.3.9" - checksum: 10c0/05c5368c13b662ee4c122c7bfbe5dc0b613416672a829f3e78bc49a357a197e0218d6e74e7c66cfcd04e15a179acab080bd3c69658c9fbefd0e1ccd950a07fc6 - languageName: node - linkType: hard - "@emnapi/core@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -611,30 +602,20 @@ __metadata: languageName: node linkType: hard -"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": +"@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:0.3.9": - version: 0.3.9 - resolution: "@jridgewell/trace-mapping@npm:0.3.9" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.0.3" - "@jridgewell/sourcemap-codec": "npm:^1.4.10" - checksum: 10c0/fa425b606d7c7ee5bfa6a31a7b050dd5814b4082f318e0e4190f991902181b4330f43f4805db1dd4f2433fd0ed9cc7a7b9c2683f1deeab1df1b0a98b1e24055b - languageName: node - linkType: hard - "@jridgewell/trace-mapping@npm:^0.3.24": version: 0.3.30 resolution: "@jridgewell/trace-mapping@npm:0.3.30" @@ -973,34 +954,6 @@ __metadata: languageName: node linkType: hard -"@tsconfig/node10@npm:^1.0.7": - version: 1.0.11 - resolution: "@tsconfig/node10@npm:1.0.11" - checksum: 10c0/28a0710e5d039e0de484bdf85fee883bfd3f6a8980601f4d44066b0a6bcd821d31c4e231d1117731c4e24268bd4cf2a788a6787c12fc7f8d11014c07d582783c - languageName: node - linkType: hard - -"@tsconfig/node12@npm:^1.0.7": - version: 1.0.11 - resolution: "@tsconfig/node12@npm:1.0.11" - checksum: 10c0/dddca2b553e2bee1308a056705103fc8304e42bb2d2cbd797b84403a223b25c78f2c683ec3e24a095e82cd435387c877239bffcb15a590ba817cd3f6b9a99fd9 - languageName: node - linkType: hard - -"@tsconfig/node14@npm:^1.0.0": - version: 1.0.3 - resolution: "@tsconfig/node14@npm:1.0.3" - checksum: 10c0/67c1316d065fdaa32525bc9449ff82c197c4c19092b9663b23213c8cbbf8d88b6ed6a17898e0cbc2711950fbfaf40388938c1c748a2ee89f7234fc9e7fe2bf44 - languageName: node - linkType: hard - -"@tsconfig/node16@npm:^1.0.2": - version: 1.0.4 - resolution: "@tsconfig/node16@npm:1.0.4" - checksum: 10c0/05f8f2734e266fb1839eb1d57290df1664fe2aa3b0fdd685a9035806daa635f7519bf6d5d9b33f6e69dd545b8c46bd6e2b5c79acb2b1f146e885f7f11a42a5bb - languageName: node - linkType: hard - "@tybys/wasm-util@npm:^0.10.2": version: 0.10.2 resolution: "@tybys/wasm-util@npm:0.10.2" @@ -1299,8 +1252,6 @@ __metadata: "@typescript-eslint/parser": "npm:^8.61.0" "@vitest/coverage-v8": "npm:^4.1.8" eslint: "npm:^9.0.0" - ts-node: "npm:^10.9.2" - tsconfig-paths: "npm:^4.2.0" tsup: "npm:^8.5.1" tsx: "npm:^4.22.4" typescript: "npm:^6.0.3" @@ -1324,16 +1275,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.1.1": - version: 8.3.4 - resolution: "acorn-walk@npm:8.3.4" - dependencies: - acorn: "npm:^8.11.0" - checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 - languageName: node - linkType: hard - -"acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.4.1": +"acorn@npm:^8.15.0": version: 8.15.0 resolution: "acorn@npm:8.15.0" bin: @@ -1398,13 +1340,6 @@ __metadata: languageName: node linkType: hard -"arg@npm:^4.1.0": - version: 4.1.3 - resolution: "arg@npm:4.1.3" - checksum: 10c0/070ff801a9d236a6caa647507bdcc7034530604844d64408149a26b9e87c2f97650055c0f049abd1efc024b334635c01f29e0b632b371ac3f26130f4cf65997a - languageName: node - linkType: hard - "argparse@npm:^2.0.1": version: 2.0.1 resolution: "argparse@npm:2.0.1" @@ -1594,13 +1529,6 @@ __metadata: languageName: node linkType: hard -"create-require@npm:^1.1.0": - version: 1.1.1 - resolution: "create-require@npm:1.1.1" - checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91 - languageName: node - linkType: hard - "cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -1650,13 +1578,6 @@ __metadata: languageName: node linkType: hard -"diff@npm:^4.0.1": - version: 4.0.2 - resolution: "diff@npm:4.0.2" - checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1 - languageName: node - linkType: hard - "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -2408,15 +2329,6 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.2": - version: 2.2.3 - resolution: "json5@npm:2.2.3" - bin: - json5: lib/cli.js - checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c - languageName: node - linkType: hard - "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -2638,13 +2550,6 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1": - version: 1.3.6 - resolution: "make-error@npm:1.3.6" - checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f - languageName: node - linkType: hard - "make-fetch-happen@npm:^14.0.3": version: 14.0.3 resolution: "make-fetch-happen@npm:14.0.3" @@ -2691,13 +2596,6 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.6": - version: 1.2.8 - resolution: "minimist@npm:1.2.8" - checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 - languageName: node - linkType: hard - "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -3395,13 +3293,6 @@ __metadata: languageName: node linkType: hard -"strip-bom@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-bom@npm:3.0.0" - checksum: 10c0/51201f50e021ef16672593d7434ca239441b7b760e905d9f33df6e4f3954ff54ec0e0a06f100d028af0982d6f25c35cd5cda2ce34eaebccd0250b8befb90d8f1 - languageName: node - linkType: hard - "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -3541,55 +3432,6 @@ __metadata: languageName: node linkType: hard -"ts-node@npm:^10.9.2": - version: 10.9.2 - resolution: "ts-node@npm:10.9.2" - dependencies: - "@cspotcode/source-map-support": "npm:^0.8.0" - "@tsconfig/node10": "npm:^1.0.7" - "@tsconfig/node12": "npm:^1.0.7" - "@tsconfig/node14": "npm:^1.0.0" - "@tsconfig/node16": "npm:^1.0.2" - acorn: "npm:^8.4.1" - acorn-walk: "npm:^8.1.1" - arg: "npm:^4.1.0" - create-require: "npm:^1.1.0" - diff: "npm:^4.0.1" - make-error: "npm:^1.1.1" - v8-compile-cache-lib: "npm:^3.0.1" - yn: "npm:3.1.1" - peerDependencies: - "@swc/core": ">=1.2.50" - "@swc/wasm": ">=1.2.50" - "@types/node": "*" - typescript: ">=2.7" - peerDependenciesMeta: - "@swc/core": - optional: true - "@swc/wasm": - optional: true - bin: - ts-node: dist/bin.js - ts-node-cwd: dist/bin-cwd.js - ts-node-esm: dist/bin-esm.js - ts-node-script: dist/bin-script.js - ts-node-transpile-only: dist/bin-transpile.js - ts-script: dist/bin-script-deprecated.js - checksum: 10c0/5f29938489f96982a25ba650b64218e83a3357d76f7bede80195c65ab44ad279c8357264639b7abdd5d7e75fc269a83daa0e9c62fd8637a3def67254ecc9ddc2 - languageName: node - linkType: hard - -"tsconfig-paths@npm:^4.2.0": - version: 4.2.0 - resolution: "tsconfig-paths@npm:4.2.0" - dependencies: - json5: "npm:^2.2.2" - minimist: "npm:^1.2.6" - strip-bom: "npm:^3.0.0" - checksum: 10c0/09a5877402d082bb1134930c10249edeebc0211f36150c35e1c542e5b91f1047b1ccf7da1e59babca1ef1f014c525510f4f870de7c9bda470c73bb4e2721b3ea - languageName: node - linkType: hard - "tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -3724,13 +3566,6 @@ __metadata: languageName: node linkType: hard -"v8-compile-cache-lib@npm:^3.0.1": - version: 3.0.1 - resolution: "v8-compile-cache-lib@npm:3.0.1" - checksum: 10c0/bdc36fb8095d3b41df197f5fb6f11e3a26adf4059df3213e3baa93810d8f0cc76f9a74aaefc18b73e91fe7e19154ed6f134eda6fded2e0f1c8d2272ed2d2d391 - languageName: node - linkType: hard - "vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": version: 8.0.16 resolution: "vite@npm:8.0.16" @@ -3933,13 +3768,6 @@ __metadata: languageName: node linkType: hard -"yn@npm:3.1.1": - version: 3.1.1 - resolution: "yn@npm:3.1.1" - checksum: 10c0/0732468dd7622ed8a274f640f191f3eaf1f39d5349a1b72836df484998d7d9807fbea094e2f5486d6b0cd2414aad5775972df0e68f8604db89a239f0f4bf7443 - languageName: node - linkType: hard - "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 58e45ff77065562edf46e0581bc47e203c917e0b Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Fri, 26 Jun 2026 23:28:39 -0700 Subject: [PATCH 02/10] Bump all dev dependencies to latest Upgraded every devDependency to its highest available version. Verified green: typecheck, lint, 1091 tests, build, npm pack, and example all pass; yarn npm audit reports no advisories. - @types/node ^24.13.2 -> ^26.0.1 - @typescript-eslint/* ^8.61.0 -> ^8.62.0 - @vitest/coverage-v8 ^4.1.8 -> ^4.1.9 - vitest ^4.1.8 -> ^4.1.9 - eslint ^9.0.0 -> ^10.6.0 Already at latest (unchanged): tsup 8.5.1, tsx 4.22.4, typescript 6.0.3 (typescript is additionally capped at <6.1.0 by typescript-eslint's peer range). Note: eslint 10 and vitest 4 require Node >=20 for development, but this only affects the dev toolchain. The published package's `engines` (>=18.17) is unchanged since the built output has no such requirement for consumers. Co-Authored-By: Claude Opus 4.8 --- package.json | 12 +- yarn.lock | 552 ++++++++++++++++++++------------------------------- 2 files changed, 223 insertions(+), 341 deletions(-) diff --git a/package.json b/package.json index 0e9f400..2fc3581 100644 --- a/package.json +++ b/package.json @@ -79,14 +79,14 @@ "node": ">=18.17" }, "devDependencies": { - "@types/node": "^24.13.2", - "@typescript-eslint/eslint-plugin": "^8.61.0", - "@typescript-eslint/parser": "^8.61.0", - "@vitest/coverage-v8": "^4.1.8", - "eslint": "^9.0.0", + "@types/node": "^26.0.1", + "@typescript-eslint/eslint-plugin": "^8.62.0", + "@typescript-eslint/parser": "^8.62.0", + "@vitest/coverage-v8": "^4.1.9", + "eslint": "^10.6.0", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", - "vitest": "^4.1.8" + "vitest": "^4.1.9" } } diff --git a/yarn.lock b/yarn.lock index 08e48a8..2e10cfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -461,80 +461,56 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": +"@eslint-community/regexpp@npm:^4.12.2": version: 4.12.2 resolution: "@eslint-community/regexpp@npm:4.12.2" checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint/config-array@npm:^0.21.2": - version: 0.21.2 - resolution: "@eslint/config-array@npm:0.21.2" +"@eslint/config-array@npm:^0.23.5": + version: 0.23.5 + resolution: "@eslint/config-array@npm:0.23.5" dependencies: - "@eslint/object-schema": "npm:^2.1.7" + "@eslint/object-schema": "npm:^3.0.5" debug: "npm:^4.3.1" - minimatch: "npm:^3.1.5" - checksum: 10c0/89dfe815d18456177c0a1f238daf4593107fd20298b3598e0103054360d3b8d09d967defd8318f031185d68df1f95cfa68becf1390a9c5c6887665f1475142e3 + minimatch: "npm:^10.2.4" + checksum: 10c0/b24833c4c76e78ee075d306cd3f095db46b2db0f90cc13a6ee6e4275f9889731c05bf5403ab5fefb79c756e07ac9184ed0e04570341382f9eccbccc80e6d1a0c languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.4.2": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" +"@eslint/config-helpers@npm:^0.6.0": + version: 0.6.0 + resolution: "@eslint/config-helpers@npm:0.6.0" dependencies: - "@eslint/core": "npm:^0.17.0" - checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 + "@eslint/core": "npm:^1.2.1" + checksum: 10c0/f9af20e8b60b0ba27edb74b8eb40c0c5d51a9bf9baf9e053bb57833a87cb0a1c49b4dfaad88fc24d49c907ad1324c8a0b668684fa9c321351dac4bc9155ec10a languageName: node linkType: hard -"@eslint/core@npm:^0.17.0": - version: 0.17.0 - resolution: "@eslint/core@npm:0.17.0" +"@eslint/core@npm:^1.2.1": + version: 1.2.1 + resolution: "@eslint/core@npm:1.2.1" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/9a580f2246633bc752298e7440dd942ec421860d1946d0801f0423830e67887e4aeba10ab9a23d281727a978eb93d053d1922a587d502942a713607f40ed704e - languageName: node - linkType: hard - -"@eslint/eslintrc@npm:^3.3.5": - version: 3.3.5 - resolution: "@eslint/eslintrc@npm:3.3.5" - dependencies: - ajv: "npm:^6.14.0" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.1" - minimatch: "npm:^3.1.5" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/9fb9f1ca65e46d6173966e3aaa5bd353e3a65d7f1f582bebf77f578fab7d7960a399fac1ecfb1e7d52bd61f5cefd6531087ca52a3a3c388f2e1b4f1ebd3da8b7 - languageName: node - linkType: hard - -"@eslint/js@npm:9.39.4": - version: 9.39.4 - resolution: "@eslint/js@npm:9.39.4" - checksum: 10c0/5aa7dea2cbc5decf7f5e3b0c6f86a084ccee0f792d288ca8e839f8bc1b64e03e227068968e49b26096e6f71fd857ab6e42691d1b993826b9a3883f1bdd7a0e46 + checksum: 10c0/10979b40588ecfef771fcb5013a542a35fb30692cc95a65f3481b0b36fbd89f5679efeb30d57f4eed35203d859aabace2a620177d6c536f71b299a1af2f3398f languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.7": - version: 2.1.7 - resolution: "@eslint/object-schema@npm:2.1.7" - checksum: 10c0/936b6e499853d1335803f556d526c86f5fe2259ed241bc665000e1d6353828edd913feed43120d150adb75570cae162cf000b5b0dfc9596726761c36b82f4e87 +"@eslint/object-schema@npm:^3.0.5": + version: 3.0.5 + resolution: "@eslint/object-schema@npm:3.0.5" + checksum: 10c0/1db337431f520b99e9edda64ef5fafd7ec6a029843eeb608753025125b6649d861d843cffafafd3c4e37926d7d5f9ec0c6a8e3665c13c3da2144e8132892e92e languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/plugin-kit@npm:0.4.1" +"@eslint/plugin-kit@npm:^0.7.2": + version: 0.7.2 + resolution: "@eslint/plugin-kit@npm:0.7.2" dependencies: - "@eslint/core": "npm:^0.17.0" + "@eslint/core": "npm:^1.2.1" levn: "npm:^0.4.1" - checksum: 10c0/51600f78b798f172a9915dffb295e2ffb44840d583427bc732baf12ecb963eb841b253300e657da91d890f4b323d10a1bd12934bf293e3018d8bb66fdce5217b + checksum: 10c0/aafba08077bcd6d7dde6c2e21db18086046a88f914f29971a84cac9ad2d48952ded1b293e665e523805297eff756522dafa16f0062195e2c7143dcd1d47d11ed languageName: node linkType: hard @@ -979,6 +955,13 @@ __metadata: languageName: node linkType: hard +"@types/esrecurse@npm:^4.3.1": + version: 4.3.1 + resolution: "@types/esrecurse@npm:4.3.1" + checksum: 10c0/90dad74d5da3ad27606d8e8e757322f33171cfeaa15ad558b615cf71bb2a516492d18f55f4816384685a3eb2412142e732bbae9a4a7cd2cf3deb7572aa4ebe03 + languageName: node + linkType: hard + "@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6": version: 1.0.8 resolution: "@types/estree@npm:1.0.8" @@ -986,6 +969,13 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:^1.0.8": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -993,114 +983,114 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^24.13.2": - version: 24.13.2 - resolution: "@types/node@npm:24.13.2" +"@types/node@npm:^26.0.1": + version: 26.0.1 + resolution: "@types/node@npm:26.0.1" dependencies: - undici-types: "npm:~7.18.0" - checksum: 10c0/d7d48a88a4feb0a6aac3cbfaf9ef3b12752b4b09447f88dd0b4c77c03b281e3d4330fe6982a99aedcd63fc16c7540a0c248b91eb2abb0b3edd884d7fe684e9ea + undici-types: "npm:~8.3.0" + checksum: 10c0/31d204333c70124da6bcac7d1f27d8980149fe3f95a8419bfcd19c3e5823705c0e370d71ac34399352e1263b2d5fc41c87af964ec81e5a05a32224d65d8d224e languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.61.0" +"@typescript-eslint/eslint-plugin@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.62.0" dependencies: "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.61.0" - "@typescript-eslint/type-utils": "npm:8.61.0" - "@typescript-eslint/utils": "npm:8.61.0" - "@typescript-eslint/visitor-keys": "npm:8.61.0" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/type-utils": "npm:8.62.0" + "@typescript-eslint/utils": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" ts-api-utils: "npm:^2.5.0" peerDependencies: - "@typescript-eslint/parser": ^8.61.0 + "@typescript-eslint/parser": ^8.62.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/1141253e18424a9a21d253dcf28e166894b6b914f2138b3e016144e451385f8e23f0f02028c7bd2d21c81953c52e478657aa9e5888cd0bdffdb8d68aab736878 + checksum: 10c0/2f257bb53a3b9b8b6f56f6b85c68ce193c7ba4cddf7d2d1995b0aec0591230859f808b3a1a85f0b7ed581f938a88efeacccc730aec307e80e1c14fc669222f9a languageName: node linkType: hard -"@typescript-eslint/parser@npm:^8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/parser@npm:8.61.0" +"@typescript-eslint/parser@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/parser@npm:8.62.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.61.0" - "@typescript-eslint/types": "npm:8.61.0" - "@typescript-eslint/typescript-estree": "npm:8.61.0" - "@typescript-eslint/visitor-keys": "npm:8.61.0" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" debug: "npm:^4.4.3" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/39b122ab20a3b5fbd4e66874f60917b37c49f32eb987531797561d2f96b443e81546f1c9d03d44d37dede89098276e5d8d0f05c1e5f9e1b998f8cf6c24e8e5e7 + checksum: 10c0/eab526557fb679c862a0462e841581e3ca24a88b8f19764630e7d10e01ba01143906100a051c8bede36e3c6eb6bef21c14ac7dccf9d41e04c84448c058450a94 languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/project-service@npm:8.61.0" +"@typescript-eslint/project-service@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/project-service@npm:8.62.0" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.61.0" - "@typescript-eslint/types": "npm:^8.61.0" + "@typescript-eslint/tsconfig-utils": "npm:^8.62.0" + "@typescript-eslint/types": "npm:^8.62.0" debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/8ff86b93bfcf103a42e8e996e11c46ded83da07d3a0bc8bd9ec4d536116d7f6253a404786510ab13847e69d6e185b17d15d7140075c26966e9b4f85c03296f21 + checksum: 10c0/4369e9ec0c8b2ce6e6cf90142ad781ef99b57350beb4ae48751871e8894e95a8f929de2f56d73849ec0166d2cdb345e3e7a42d30ea8463d3f1b65607648ac582 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/scope-manager@npm:8.61.0" +"@typescript-eslint/scope-manager@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/scope-manager@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.61.0" - "@typescript-eslint/visitor-keys": "npm:8.61.0" - checksum: 10c0/76cdf1c181ebbc706ddc8b2366e8ebfda529c13d82ff10c0797c96c0b38dd82f6471b24995f58ac267194a753b23d77452d925dd615b1e651922ddbe6e451c6b + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" + checksum: 10c0/1e7192b6bf18955ee76861321a92e08815f1bc3feab23861b6330dde8343dfb346a47c7c8bf3d94b0897a98184adcf283568b5857a0e5d509ce0c21c6cf4cc44 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.61.0, @typescript-eslint/tsconfig-utils@npm:^8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.61.0" +"@typescript-eslint/tsconfig-utils@npm:8.62.0, @typescript-eslint/tsconfig-utils@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.62.0" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/b498675f14ef90a5730de7c58388eb2522085a56c3fcad42ad9f89320b96221eafb5b4f9650375f29092025153d03533e3f23ea8f45ce3bc95a57593059edef3 + checksum: 10c0/9423908009e95b8bba8ac2ad1e4bf4bc9dd7052fa44be613659f81aad363787bf7fa1ea017eb9dcb059fed41866bc2d8e50f1cf41c7dfad25a4431c333fbf2fa languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/type-utils@npm:8.61.0" +"@typescript-eslint/type-utils@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/type-utils@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.61.0" - "@typescript-eslint/typescript-estree": "npm:8.61.0" - "@typescript-eslint/utils": "npm:8.61.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" + "@typescript-eslint/utils": "npm:8.62.0" debug: "npm:^4.4.3" ts-api-utils: "npm:^2.5.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/6347f451301ca7089500fe6eb3b98e5efd769e56ffda07eb735130fd209b9053c02e952b6fda7a15acf7851fb63f11fc50166ba8bd90513480732c599644b36b + checksum: 10c0/caf6a0072ff48e9351564dbc856c095fd5233bf2176daf9e8361a534be9ded6835c984ff8867365ca3cba158189074b14cc199b19c1a2e20d09682170c3784db languageName: node linkType: hard -"@typescript-eslint/types@npm:8.61.0, @typescript-eslint/types@npm:^8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/types@npm:8.61.0" - checksum: 10c0/c19407d66fb5ad26e2670cd272bee91d150087d917752422257759e17920220af27cd54593205e9726367a440a237bf8d27ed805cae0b282a79172161f007207 +"@typescript-eslint/types@npm:8.62.0, @typescript-eslint/types@npm:^8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/types@npm:8.62.0" + checksum: 10c0/28d7a6851cb79301ef1ee004fb8d75811e52eb2a7258d7f8ad2f234886ab2faaf1888cbf5a71cb53afd4d1024c51f71ea359f3103ea70d6f7ccd626ffbfd49c1 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.61.0" +"@typescript-eslint/typescript-estree@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.62.0" dependencies: - "@typescript-eslint/project-service": "npm:8.61.0" - "@typescript-eslint/tsconfig-utils": "npm:8.61.0" - "@typescript-eslint/types": "npm:8.61.0" - "@typescript-eslint/visitor-keys": "npm:8.61.0" + "@typescript-eslint/project-service": "npm:8.62.0" + "@typescript-eslint/tsconfig-utils": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/visitor-keys": "npm:8.62.0" debug: "npm:^4.4.3" minimatch: "npm:^10.2.2" semver: "npm:^7.7.3" @@ -1108,41 +1098,41 @@ __metadata: ts-api-utils: "npm:^2.5.0" peerDependencies: typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/460819feeca826bfd895f821a5008c3eaa79b9495259641976fdc6ec319a7e9587bc28603437ea3d9a10c3b28037f1dea883cbe8d2858616dd33847e8db2179e + checksum: 10c0/c1b76203f37870e66487379c75b1d1a9af0a9c88e8e58a3d2fc106427ce42dce9bd7358a3d2cb7d344004cbb693dba899abf19391d853bbb9f345aa8683635c4 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/utils@npm:8.61.0" +"@typescript-eslint/utils@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/utils@npm:8.62.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.61.0" - "@typescript-eslint/types": "npm:8.61.0" - "@typescript-eslint/typescript-estree": "npm:8.61.0" + "@typescript-eslint/scope-manager": "npm:8.62.0" + "@typescript-eslint/types": "npm:8.62.0" + "@typescript-eslint/typescript-estree": "npm:8.62.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/f7b2241fc4defd40107243642e26697193707be12af1552c60bc414e71df1285c9cdff429f913b30ed08ae87a7e6e13388eaf05c1be5fb8310f6a63a6c4f7f73 + checksum: 10c0/5c5d1dd2c37d43ec913e4d144d071058701bf3548733671a05025fae306f7afdc4fdc3d9867d0eae32537eb68ecba046ab8aa6111a5d97497ec78fee98a25809 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.61.0": - version: 8.61.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.61.0" +"@typescript-eslint/visitor-keys@npm:8.62.0": + version: 8.62.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.62.0" dependencies: - "@typescript-eslint/types": "npm:8.61.0" + "@typescript-eslint/types": "npm:8.62.0" eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/5b656aed426a92dfc9a481f0bf535ceb47321303f476f32ba979f73423c739b51d7a5ad76c81d7be9df0a9beb361f4a11ff530dd86d59f41b89bb5f09af7be9f + checksum: 10c0/29102828fab6b060e607effee0d7666bc82aef269241c7c5c44ffb3386b3cb69ea585bf7c5d88d428c489f46d5888cbe90677e15cbad8cb27acfa1fc1661bd5b languageName: node linkType: hard -"@vitest/coverage-v8@npm:^4.1.8": - version: 4.1.8 - resolution: "@vitest/coverage-v8@npm:4.1.8" +"@vitest/coverage-v8@npm:^4.1.9": + version: 4.1.9 + resolution: "@vitest/coverage-v8@npm:4.1.9" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.1.8" + "@vitest/utils": "npm:4.1.9" ast-v8-to-istanbul: "npm:^1.0.0" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" @@ -1152,34 +1142,34 @@ __metadata: std-env: "npm:^4.0.0-rc.1" tinyrainbow: "npm:^3.1.0" peerDependencies: - "@vitest/browser": 4.1.8 - vitest: 4.1.8 + "@vitest/browser": 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/e3419115fa413e19bda5edd72c8394b255d418af75278b2cd74341257399f8e5921f78b966a547ba8d67ac8268ccdd5af019230084cc1b9a3fc8fae8283ae76b + checksum: 10c0/02bc408d5d8d5188bb569ae1df1f56903e9e19a6a19ddeff07140e1b344b82662f0669c7ee87b6f4f82c9d1d63a0296fe40c023f923a500a707463c8c05ec133 languageName: node linkType: hard -"@vitest/expect@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/expect@npm:4.1.8" +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" dependencies: "@standard-schema/spec": "npm:^1.1.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.8" - "@vitest/utils": "npm:4.1.8" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" chai: "npm:^6.2.2" tinyrainbow: "npm:^3.1.0" - checksum: 10c0/f7bf6c720d2427c3bd0b35472ebd84d963be7d09ecf52a0fb05e8c4d5d0c9ee164a8c28eee6360947be1b245b47faefab54560cb98e5cb678c1c1074260b9149 + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 languageName: node linkType: hard -"@vitest/mocker@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/mocker@npm:4.1.8" +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" dependencies: - "@vitest/spy": "npm:4.1.8" + "@vitest/spy": "npm:4.1.9" estree-walker: "npm:^3.0.3" magic-string: "npm:^0.30.21" peerDependencies: @@ -1190,56 +1180,56 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/f8cb2b8b55dc2cba0b2399aeee528b0187042f22cbc2d50a4fd6141f5aa246ebc41700f45dd1d73eca44ddfb57dcde48b2eb317bfbb1198f5ab2cc4fd04b2ea0 + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 languageName: node linkType: hard -"@vitest/pretty-format@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/pretty-format@npm:4.1.8" +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" dependencies: tinyrainbow: "npm:^3.1.0" - checksum: 10c0/553c456692a4b9ae13cd116c234c74b4495e0f1a0d5c51ffc3fab8ea085e3550769967e29db79bdac0cf127b1bf88b7f70bfba3dcc72be6bddf834433e30cc91 + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de languageName: node linkType: hard -"@vitest/runner@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/runner@npm:4.1.8" +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" dependencies: - "@vitest/utils": "npm:4.1.8" + "@vitest/utils": "npm:4.1.9" pathe: "npm:^2.0.3" - checksum: 10c0/706808a4b7b95ea9a9268fc152dd39e15a9a754f37c7990aea167486a9094caa913dae454771ae02c18dccfabd667f8cc38eed33a1307a79d32a89878b5bcce1 + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/snapshot@npm:4.1.8" +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" dependencies: - "@vitest/pretty-format": "npm:4.1.8" - "@vitest/utils": "npm:4.1.8" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/ba4c32112491d42d24986f921c50ede5edbdb4b7eafa16c72cf8d2c9ecc44121fdb3d9365236747a9841f0d6776affc6457470fcbb082df9dbc28c24792a0c6d + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 languageName: node linkType: hard -"@vitest/spy@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/spy@npm:4.1.8" - checksum: 10c0/3c10c0325a09d16bc0e77c0be96c47c15416186e33332880c0d1dd0a51d51a866091067b81f2a2ef6fb422a7760e6cf15c04d91a0eca4d59f62e8c8401fa53fc +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 languageName: node linkType: hard -"@vitest/utils@npm:4.1.8": - version: 4.1.8 - resolution: "@vitest/utils@npm:4.1.8" +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" dependencies: - "@vitest/pretty-format": "npm:4.1.8" + "@vitest/pretty-format": "npm:4.1.9" convert-source-map: "npm:^2.0.0" tinyrainbow: "npm:^3.1.0" - checksum: 10c0/acda9d3d640c1ebc81afb358ac30589d7d7d583af81e2d09419f0af9cbe41f3ce0b90527326943bf0da51614be5fc31afcd32259f6beb32b3417999d6ef380f3 + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 languageName: node linkType: hard @@ -1247,15 +1237,15 @@ __metadata: version: 0.0.0-use.local resolution: "@yipe/dice@workspace:." dependencies: - "@types/node": "npm:^24.13.2" - "@typescript-eslint/eslint-plugin": "npm:^8.61.0" - "@typescript-eslint/parser": "npm:^8.61.0" - "@vitest/coverage-v8": "npm:^4.1.8" - eslint: "npm:^9.0.0" + "@types/node": "npm:^26.0.1" + "@typescript-eslint/eslint-plugin": "npm:^8.62.0" + "@typescript-eslint/parser": "npm:^8.62.0" + "@vitest/coverage-v8": "npm:^4.1.9" + eslint: "npm:^10.6.0" tsup: "npm:^8.5.1" tsx: "npm:^4.22.4" typescript: "npm:^6.0.3" - vitest: "npm:^4.1.8" + vitest: "npm:^4.1.9" languageName: unknown linkType: soft @@ -1284,6 +1274,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.16.0": + version: 8.17.0 + resolution: "acorn@npm:8.17.0" + bin: + acorn: bin/acorn + checksum: 10c0/5dcefea5f8f023b6cc24cbe71fb5a8112b601d36c4fa07d14e4e6ffc2ee47383332c46b36c766d9437725aa6660156eae50efa0c838719823b50d7c327c4ed42 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.4 resolution: "agent-base@npm:7.1.4" @@ -1317,7 +1316,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": +"ansi-styles@npm:^4.0.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: @@ -1340,13 +1339,6 @@ __metadata: languageName: node linkType: hard -"argparse@npm:^2.0.1": - version: 2.0.1 - resolution: "argparse@npm:2.0.1" - checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e - languageName: node - linkType: hard - "ast-v8-to-istanbul@npm:^1.0.0": version: 1.0.4 resolution: "ast-v8-to-istanbul@npm:1.0.4" @@ -1372,16 +1364,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/975fecac2bb7758c062c20d0b3b6288c7cc895219ee25f0a64a9de662dbac981ff0b6e89909c3897c1f84fa353113a721923afdec5f8b2350255b097f12b1f73 - languageName: node - linkType: hard - "brace-expansion@npm:^2.0.1": version: 2.0.2 resolution: "brace-expansion@npm:2.0.2" @@ -1438,13 +1420,6 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": - version: 3.1.0 - resolution: "callsites@npm:3.1.0" - checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 - languageName: node - linkType: hard - "chai@npm:^6.2.2": version: 6.2.2 resolution: "chai@npm:6.2.2" @@ -1452,16 +1427,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": - version: 4.1.2 - resolution: "chalk@npm:4.1.2" - dependencies: - ansi-styles: "npm:^4.1.0" - supports-color: "npm:^7.1.0" - checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 - languageName: node - linkType: hard - "chokidar@npm:^4.0.3": version: 4.0.3 resolution: "chokidar@npm:4.0.3" @@ -1501,13 +1466,6 @@ __metadata: languageName: node linkType: hard -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - "confbox@npm:^0.1.8": version: 0.1.8 resolution: "confbox@npm:0.1.8" @@ -1814,13 +1772,15 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" +"eslint-scope@npm:^9.1.2": + version: 9.1.2 + resolution: "eslint-scope@npm:9.1.2" dependencies: + "@types/esrecurse": "npm:^4.3.1" + "@types/estree": "npm:^1.0.8" esrecurse: "npm:^4.3.0" estraverse: "npm:^5.2.0" - checksum: 10c0/407f6c600204d0f3705bd557f81bd0189e69cd7996f408f8971ab5779c0af733d1af2f1412066b40ee1588b085874fc37a2333986c6521669cdbdd36ca5058e0 + checksum: 10c0/9fb8bca5a73e5741efb6cec84467027b6cb6f4203ff9b43a938e272c5cd30800bde46a5c20dfd1609f840225f0b62b7673be391b20acadf8658ca9fa4729b3dd languageName: node linkType: hard @@ -1831,45 +1791,35 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^5.0.0": +"eslint-visitor-keys@npm:^5.0.0, eslint-visitor-keys@npm:^5.0.1": version: 5.0.1 resolution: "eslint-visitor-keys@npm:5.0.1" checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 languageName: node linkType: hard -"eslint@npm:^9.0.0": - version: 9.39.4 - resolution: "eslint@npm:9.39.4" +"eslint@npm:^10.6.0": + version: 10.6.0 + resolution: "eslint@npm:10.6.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.8.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.2" - "@eslint/config-helpers": "npm:^0.4.2" - "@eslint/core": "npm:^0.17.0" - "@eslint/eslintrc": "npm:^3.3.5" - "@eslint/js": "npm:9.39.4" - "@eslint/plugin-kit": "npm:^0.4.1" + "@eslint-community/regexpp": "npm:^4.12.2" + "@eslint/config-array": "npm:^0.23.5" + "@eslint/config-helpers": "npm:^0.6.0" + "@eslint/core": "npm:^1.2.1" + "@eslint/plugin-kit": "npm:^0.7.2" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" "@types/estree": "npm:^1.0.6" ajv: "npm:^6.14.0" - chalk: "npm:^4.0.0" cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.4.0" - eslint-visitor-keys: "npm:^4.2.1" - espree: "npm:^10.4.0" - esquery: "npm:^1.5.0" + eslint-scope: "npm:^9.1.2" + eslint-visitor-keys: "npm:^5.0.1" + espree: "npm:^11.2.0" + esquery: "npm:^1.7.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" file-entry-cache: "npm:^8.0.0" @@ -1879,8 +1829,7 @@ __metadata: imurmurhash: "npm:^0.1.4" is-glob: "npm:^4.0.0" json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.5" + minimatch: "npm:^10.2.4" natural-compare: "npm:^1.4.0" optionator: "npm:^0.9.3" peerDependencies: @@ -1890,22 +1839,22 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/1955067c2d991f0c84f4c4abfafe31bb47fa3b717a7fd3e43fe1e511c6f859d7700cbca969f85661dc4c130f7aeced5e5444884314198a54428f5e5141db9337 + checksum: 10c0/ebe0261fc750afb7f1a0c5a14f5288e57b971e0bee9754b1620132d22ad0c23690183977b0c9514ffa7ad460768d689d3fb9792ad9267b6c6205e2e0c17d8563 languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" +"espree@npm:^11.2.0": + version: 11.2.0 + resolution: "espree@npm:11.2.0" dependencies: - acorn: "npm:^8.15.0" + acorn: "npm:^8.16.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b + eslint-visitor-keys: "npm:^5.0.1" + checksum: 10c0/cf87e18ffd9dc113eb8d16588e7757701bc10c9934a71cce8b89c2611d51672681a918307bd6b19ac3ccd0e7ba1cbccc2f815b36b52fa7e73097b251014c3d81 languageName: node linkType: hard -"esquery@npm:^1.5.0": +"esquery@npm:^1.7.0": version: 1.7.0 resolution: "esquery@npm:1.7.0" dependencies: @@ -2103,13 +2052,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d - languageName: node - linkType: hard - "graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -2181,16 +2123,6 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec - languageName: node - linkType: hard - "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -2297,17 +2229,6 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.2.0 - resolution: "js-yaml@npm:4.2.0" - dependencies: - argparse: "npm:^2.0.1" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/1916456c118746603b067d74bbcbb0445d9a1d5e474ad4ae775e7b20525bed902e01d9d97dd0c81fcd8d4f596162309d0eb057f4aa38f3e9647f14075e9dea45 - languageName: node - linkType: hard - "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -2498,13 +2419,6 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": - version: 4.6.2 - resolution: "lodash.merge@npm:4.6.2" - checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 - languageName: node - linkType: hard - "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -2569,7 +2483,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2": +"minimatch@npm:^10.2.2, minimatch@npm:^10.2.4": version: 10.2.5 resolution: "minimatch@npm:10.2.5" dependencies: @@ -2578,15 +2492,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.1.5": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - "minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -2825,15 +2730,6 @@ __metadata: languageName: node linkType: hard -"parent-module@npm:^1.0.0": - version: 1.0.1 - resolution: "parent-module@npm:1.0.1" - dependencies: - callsites: "npm:^3.0.0" - checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 - languageName: node - linkType: hard - "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -2976,13 +2872,6 @@ __metadata: languageName: node linkType: hard -"resolve-from@npm:^4.0.0": - version: 4.0.0 - resolution: "resolve-from@npm:4.0.0" - checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 - languageName: node - linkType: hard - "resolve-from@npm:^5.0.0": version: 5.0.0 resolution: "resolve-from@npm:5.0.0" @@ -3293,13 +3182,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^3.1.1": - version: 3.1.1 - resolution: "strip-json-comments@npm:3.1.1" - checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd - languageName: node - linkType: hard - "sucrase@npm:^3.35.0": version: 3.35.0 resolution: "sucrase@npm:3.35.0" @@ -3532,10 +3414,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.18.0": - version: 7.18.2 - resolution: "undici-types@npm:7.18.2" - checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a languageName: node linkType: hard @@ -3623,17 +3505,17 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^4.1.8": - version: 4.1.8 - resolution: "vitest@npm:4.1.8" +"vitest@npm:^4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" dependencies: - "@vitest/expect": "npm:4.1.8" - "@vitest/mocker": "npm:4.1.8" - "@vitest/pretty-format": "npm:4.1.8" - "@vitest/runner": "npm:4.1.8" - "@vitest/snapshot": "npm:4.1.8" - "@vitest/spy": "npm:4.1.8" - "@vitest/utils": "npm:4.1.8" + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" es-module-lexer: "npm:^2.0.0" expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" @@ -3651,12 +3533,12 @@ __metadata: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.8 - "@vitest/browser-preview": 4.1.8 - "@vitest/browser-webdriverio": 4.1.8 - "@vitest/coverage-istanbul": 4.1.8 - "@vitest/coverage-v8": 4.1.8 - "@vitest/ui": 4.1.8 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 happy-dom: "*" jsdom: "*" vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3686,8 +3568,8 @@ __metadata: vite: optional: false bin: - vitest: vitest.mjs - checksum: 10c0/f459c500f8818c7a2318cd23228b4e5c0b5efb25bf8e5cb7f116c6d26e51482b2f800a8bb19837c0b5f0d05c51519edbf502bc8ceb5bd86868e8facf1d2c498e + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 languageName: node linkType: hard From c9fbd78cbf706087ffefe71fd405e3b9edfa40ac Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 09:07:12 -0700 Subject: [PATCH 03/10] perf: closed-form moments + lazy combined distribution in DiceQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance pass using statistical identities. No change to the materialized `combined` distribution; all 1085 prior tests pass (1102 with new coverage), plus typecheck/lint/build green. Profiling showed the DiceQuery constructor's eager N-way convolution dominates cost (~17 ms for a heavy 4-attack expression), while the resulting mean/variance are essentially free to read. Two statistical optimizations: - mean()/variance()/stddev() now use moment additivity for independent attacks (E[ΣX]=ΣE[X], Var[ΣX]=ΣVar[X]), computed directly from the single PMFs. - `combined` is now built lazily (first access) rather than in the constructor. Together, a query used only for DPR/mean/variance never convolves: ~17 ms -> ~0.001 ms (~15000x) for the heavy expression. Values are bit-exact for already-normalized singles and otherwise differ by at most a few ULP (within the library's tolerances; verified max relative error ~1e-15 mean / ~5e-11 variance across all test expressions at 1-4 attacks). Also: - combinedWithAttribution() reuses `combined` when all singles already carry attribution (parser PMFs do) — bit-for-bit identical, skips a redundant convolution pass (~1.8x on the warm chart path). - Added PMF.hasAttribution() (O(1)); withAttribution() reuses it. Deliberately did NOT replace the convolution core: a fused/allocation-lean inner loop is ~1.5x faster but not bit-identical (floating-point addition isn't associative), which isn't worth silently shifting published numeric results. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 ++++++++ src/pmf/pmf.ts | 20 ++++++--- src/pmf/query.ts | 77 +++++++++++++++++++++++++++------- tests/statistical-perf.test.ts | 61 +++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 tests/statistical-perf.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8366930..501b12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`PMF.toJSONString()`** — returns the JSON string form (the previous `toJSON()` behavior). - **`DiceQuery.stdev()`** — alias of `stddev()`, matching `PMF.stdev()`. +- **`PMF.hasAttribution()`** — O(1) check for whether a PMF already carries + damage-attribution metadata. + +### Performance + +- **`DiceQuery.mean()` / `variance()` / `stddev()` use moment additivity** + (`E[ΣX]=ΣE[X]`, `Var[ΣX]=ΣVar[X]`) computed directly from the single PMFs. +- **`DiceQuery.combined` is now built lazily** (on first access) instead of in + the constructor. Combined with the above, a query used only for DPR / mean / + variance never performs the N-way convolution — multi-attack stats-only + queries are ~10000× faster (e.g. ~17 ms → ~0.001 ms for a heavy 4-attack + expression). The materialized `combined` distribution is unchanged; mean and + variance may differ from the previous convolution-based values by at most a + few ULP (well within the library's tolerances). +- **`DiceQuery.combinedWithAttribution()` reuses `combined`** when every single + already carries attribution (as parser-generated PMFs do), avoiding a + redundant convolution pass. Result is bit-for-bit identical. ### Changed diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index 03d4d49..f5938bf 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -249,16 +249,26 @@ export class PMF { * * @returns New PMF with attr field populated in each bin */ - withAttribution(): PMF { - // Fast path: if attr already exists, return this PMF unchanged - // Check if any non-zero damage bin has attr + /** + * Returns true if this PMF already carries damage attribution metadata. + * + * Only the first positive-damage bin is inspected (parser-generated PMFs + * populate `attr` uniformly), so this is O(1) in practice. + */ + hasAttribution(): boolean { for (const [damage, bin] of this.map) { if (damage !== 0 && bin.attr && Object.keys(bin.attr).length > 0) { - return this; // Already has attribution + return true; } - // Only check first non-zero bin for performance + // Only check the first non-zero bin for performance if (damage > 0) break; } + return false; + } + + withAttribution(): PMF { + // Fast path: if attr already exists, return this PMF unchanged + if (this.hasAttribution()) return this; const newMap = new Map(); diff --git a/src/pmf/query.ts b/src/pmf/query.ts index 6ef4e6c..da2452e 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -15,7 +15,8 @@ import { PMF } from "./pmf"; export class DiceQuery { public readonly singles: PMF[]; - public readonly combined: PMF; + private readonly _eps: number; + private _combined?: PMF; private _combinedWithAttr?: PMF; constructor(singles: PMF | PMF[], combined?: PMF, eps = EPS) { @@ -23,8 +24,28 @@ export class DiceQuery { if (this.singles.some((s) => s === undefined)) { throw new Error("DiceQuery contains undefined singles"); } - const c = combined ?? PMF.convolveMany(this.singles); - this.combined = Math.abs(c.mass() - 1) <= eps ? c : c.normalize(); + this._eps = eps; + if (combined !== undefined) { + this._combined = + Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize(); + } + } + + /** + * The combined damage distribution of all single PMFs (their convolution), + * normalized to total probability 1. + * + * Computed lazily on first access and cached. Queries that only need + * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance}, + * {@link DiceQuery.stddev} — never trigger this convolution. + */ + get combined(): PMF { + if (this._combined === undefined) { + const c = PMF.convolveMany(this.singles); + this._combined = + Math.abs(c.mass() - 1) <= this._eps ? c : c.normalize(); + } + return this._combined; } private static readonly DEFAULT_OUTCOMES: readonly OutcomeType[] = [ @@ -57,8 +78,16 @@ export class DiceQuery { return this._combinedWithAttr; } - // Add attribution to each single PMF, then convolve - // The existing convolution logic will preserve attribution correctly + // Fast path: if every single already carries attribution (as parser- + // generated PMFs do), the attributed convolution is bit-for-bit identical + // to `combined` — reuse it instead of convolving a second time. + if (this.singles.every((pmf) => pmf.hasAttribution())) { + this._combinedWithAttr = this.combined; + return this._combinedWithAttr; + } + + // Otherwise (e.g. builder-generated PMFs that only carry `count`), add + // attribution to each single PMF, then convolve. const singlesWithAttr = this.singles.map((pmf) => pmf.withAttribution()); const combined = PMF.convolveMany(singlesWithAttr, this.combined.epsilon); @@ -79,11 +108,18 @@ export class DiceQuery { * Use case: "What's my average damage per round?" */ mean(): number { - let totalSum = 0; - for (const [damageValue, probabilityBin] of this.combined) { - totalSum += damageValue * probabilityBin.p; + // Expectation is additive over independent attacks: E[Σ Xᵢ] = Σ E[Xᵢ]. + // Computing it directly from the singles avoids building `combined`. + let totalMean = 0; + for (const single of this.singles) { + const mass = single.mass(); + if (mass <= 0) continue; + // Match `combined`'s semantics: only divide by mass when it is not + // already 1 (within eps), so an already-normalized single is bit-exact. + totalMean += + Math.abs(mass - 1) <= this._eps ? single.mean() : single.mean() / mass; } - return totalSum; + return totalMean; } /** @@ -94,13 +130,24 @@ export class DiceQuery { * High variance means higher risk/reward. Lower variance means more consistent damage. */ variance(): number { - const meanValue = this.mean(); - let varianceSum = 0; - for (const [damageValue, probabilityBin] of this.combined) { - const deviationFromMean = damageValue - meanValue; - varianceSum += deviationFromMean * deviationFromMean * probabilityBin.p; + // Variance is additive over independent attacks: Var[Σ Xᵢ] = Σ Var[Xᵢ]. + // Each single's variance is E[X²] − E[X]², computed from its own bins, + // so this avoids building `combined`. + let totalVariance = 0; + for (const single of this.singles) { + const mass = single.mass(); + if (mass <= 0) continue; + // As in mean(), avoid dividing by an effectively-unit mass. + const normalized = Math.abs(mass - 1) <= this._eps; + const mu = normalized ? single.mean() : single.mean() / mass; + let secondMoment = 0; + for (const [damageValue, probabilityBin] of single) { + const p = normalized ? probabilityBin.p : probabilityBin.p / mass; + secondMoment += damageValue * damageValue * p; + } + totalVariance += secondMoment - mu * mu; } - return varianceSum; + return totalVariance; } /** diff --git a/tests/statistical-perf.test.ts b/tests/statistical-perf.test.ts new file mode 100644 index 0000000..4593a5a --- /dev/null +++ b/tests/statistical-perf.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { DiceQuery, parse, PMF } from "../src/index"; + +/** + * These tests lock in the statistical performance optimizations in DiceQuery: + * - mean()/variance() use moment additivity (no convolution required) + * - `combined` is built lazily, so stats-only queries never convolve + * - combinedWithAttribution() reuses `combined` when singles already carry attr + */ +describe("Statistical performance optimizations", () => { + const exprs = [ + "(d20 + 6 AC 15) * (2d6 + 4)", + "(d20 +11 AC 10) * (4d8 + 5) crit (8d8 + 5)", + "(d20 + 6 DC 15) * 8d6 save half", + ]; + + describe("mean()/variance() are additive and match the convolution", () => { + for (const expr of exprs) { + for (const n of [1, 2, 4]) { + it(`${expr} ×${n}`, () => { + const singles = parse(expr).replicate(n); + const q = new DiceQuery(singles); + + // Reference values derived from the fully-convolved distribution. + const combined = q.combined; + let refMean = 0; + for (const [d, bin] of combined) refMean += d * bin.p; + let refVar = 0; + for (const [d, bin] of combined) + refVar += (d - refMean) * (d - refMean) * bin.p; + + expect(q.mean()).toBeCloseTo(refMean, 9); + expect(q.variance()).toBeCloseTo(refVar, 6); + expect(q.stdev()).toBeCloseTo(Math.sqrt(refVar), 6); + }); + } + } + }); + + it("mean() does not build the combined distribution (stays lazy)", () => { + const q = new DiceQuery(parse("(d20 +60 AC 25) * (672) crit (24d6 + 600)").replicate(4)); + q.mean(); + q.variance(); + q.stdev(); + // `combined` is computed lazily; stats-only access must not materialize it. + expect((q as unknown as { _combined?: PMF })._combined).toBeUndefined(); + + // Accessing combined materializes and caches it. + void q.combined; + expect((q as unknown as { _combined?: PMF })._combined).toBeDefined(); + }); + + it("combinedWithAttribution equals combined when singles carry attribution", () => { + const singles = parse("(d20 +11 AC 10) * (4d8 + 5) crit (8d8 + 5)").replicate(3); + const q = new DiceQuery(singles); + const attributed = q.combinedWithAttribution(); + + // Parser PMFs already carry attr, so this is the fast path: same object. + expect(attributed).toBe(q.combined); + }); +}); From d91cf0ca529795e1a840074ea1932be0748e5e7c Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 12:05:47 -0700 Subject: [PATCH 04/10] perf: allocation-lean convolution inner loop (~1.6x faster) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convolve now accumulates directly into each destination bin instead of building a temporary Bin per (a,b) term and merging it via mergeInto. This removes one object allocation and one merge pass per term. The probability channel accumulates in the same order, so P(damage) is bit-identical. Only the per-label count/attr provenance sums re-associate ((x+y)+z vs x+(y+z) for shared outcome labels), shifting them by at most a few ULP. Verified across 2-16 attack folds: max Δp ~6e-16 (machine epsilon, does not grow with depth), max Δcount ~1e-14 at 16 folds — ~100x below the eps=1e-12 pruning threshold. All 1102 tests pass. ~1.6x faster on the chart/distribution path (heavy 4-attack convolveMany: 16.9ms -> 10.6ms). This supersedes the earlier decision to leave the convolution core untouched: deep-fold measurements showed the re-association error is negligible and never accumulates in the probability channel. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++++ src/pmf/pmf.ts | 45 ++++++++++++++++++++++++++++++--------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 501b12b..0fd4ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`DiceQuery.combinedWithAttribution()` reuses `combined`** when every single already carries attribution (as parser-generated PMFs do), avoiding a redundant convolution pass. Result is bit-for-bit identical. +- **`PMF.convolve()` inner loop accumulates directly into destination bins** + instead of allocating a temporary bin per term and merging — ~1.6× faster + convolution (the cost of building the combined distribution for charts). The + probability channel is bit-identical; per-label `count`/`attr` provenance may + re-associate by at most a few ULP (≤1e-14 even at 16 attacks, ~100× below the + eps pruning threshold). ### Changed diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index f5938bf..ef7bc5f 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -765,30 +765,45 @@ export class PMF { const cached = pmfCache?.get(cacheKey); if (cached) return cached; + // Accumulate directly into each destination bin instead of building a + // temporary Bin per (a,b) pair and merging it. The probability channel + // (`dest.p += ap*bp`) accumulates in the same order as before, so it is + // bit-identical; only the per-label `count`/`attr` sums re-associate, which + // shifts them by at most a few ULP (far below the eps pruning threshold). const combinedMap = new Map(); for (const [aVal, aBin] of A.map) { + const ap = aBin.p; + const aCount = aBin.count; + const aAttr = aBin.attr; for (const [bVal, bBin] of B.map) { - const p = aBin.p * bBin.p; + const bp = bBin.p; const dmg = aVal + bVal; - const count: OutcomeLabelMap = {}; - for (const k in aBin.count) - count[k] = (count[k] || 0) + (aBin.count[k] as number) * bBin.p; + let dest = combinedMap.get(dmg); + if (dest === undefined) { + dest = { p: 0, count: {} }; + combinedMap.set(dmg, dest); + } + + dest.p += ap * bp; + + const dc = dest.count; + for (const k in aCount) dc[k] = (dc[k] || 0) + (aCount[k] as number) * bp; for (const k in bBin.count) - count[k] = (count[k] || 0) + (bBin.count[k] as number) * aBin.p; - - let attr: OutcomeLabelMap | undefined; - if (aBin.attr || bBin.attr) { - attr = {}; - if (aBin.attr) - for (const k in aBin.attr) - attr[k] = (attr[k] || 0) + (aBin.attr[k] as number) * bBin.p; + dc[k] = (dc[k] || 0) + (bBin.count[k] as number) * ap; + + if (aAttr || bBin.attr) { + let da = dest.attr; + if (da === undefined) { + da = {}; + dest.attr = da; + } + if (aAttr) + for (const k in aAttr) da[k] = (da[k] || 0) + (aAttr[k] as number) * bp; if (bBin.attr) for (const k in bBin.attr) - attr[k] = (attr[k] || 0) + (bBin.attr[k] as number) * aBin.p; + da[k] = (da[k] || 0) + (bBin.attr[k] as number) * ap; } - - PMF.mergeInto(combinedMap, dmg, { p, count, attr }); } } From 23e3f29f1e0f44f2c6818f5fb77563cac7e44d9c Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 12:25:29 -0700 Subject: [PATCH 05/10] perf: memoize convolve fingerprint, drop per-face clones & dead chart pass A multi-agent audit (5 discovery agents + adversarial verifiers) surfaced and vetted these safe optimizations. Each was verified bit-for-bit identical (probabilities, counts, means, variance) across the full expression corpus; all 1102 tests pass. - PMF.fingerprint(): memoize the convolution cache-key fingerprint on the immutable PMF instead of re-summing every bin key on each convolve() call (incl. cache hits). ~36% faster warm cache-hit convolves. Same key string. - Dice.calculateHitDistribution(): hoist the 6 outcome distributions out of the per-face loop and read them directly instead of cloning O(faces*6) times. ~10% faster cold parse of wide-support expressions. - DiceQuery.toStackedChartData(): remove a dead O(N*L) precomputation pass whose result was discarded. ~2x faster. - Dice.toPMF(): iterate the internal face map directly (skip getFaceMap clone) and use for-in for the save-half check; multiplyDiceByDice() uses a Map. The adversarial pass also rejected several tempting-but-unsafe ideas: power() in convolveMany (breaks public provenance semantics), inlining DiceQuery in probExactlyK (drops normalization, shifts values), an LRUCache set() tweak (empirically broke a test), and lazy fingerprint eval (cache-key collisions). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +++++++++++++ src/parser/dice.ts | 38 +++++++++++++++++++++++--------------- src/parser/parser.ts | 11 ++++++----- src/pmf/pmf.ts | 26 ++++++++++++++++---------- src/pmf/query.ts | 6 ------ 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd4ca1..990884e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 probability channel is bit-identical; per-label `count`/`attr` provenance may re-associate by at most a few ULP (≤1e-14 even at 16 attacks, ~100× below the eps pruning threshold). +- **Convolution cache-key fingerprint is memoized** on each (immutable) PMF + instead of re-summing every bin key on every `convolve()` call — ~36% faster + on warm cache hits. Bit-identical (`PMF.fingerprint()` returns the same string). +- **`Dice.calculateHitDistribution()` no longer clones outcome distributions per + face** — it reads the stored maps once instead of `O(faces × outcomes)` clones, + ~10% faster cold parsing of wide-support expressions. Bit-identical. +- **`DiceQuery.toStackedChartData()` drops a dead `O(N×L)` precomputation pass** + whose result was discarded — ~2× faster. Bit-identical. +- Minor bit-identical cleanups on the parse path (`Dice.toPMF` iterates the + internal face map directly; `multiplyDiceByDice` uses a `Map`). + + All of the above were verified bit-for-bit identical (probabilities, counts, + means, variance) across the full expression corpus. ### Changed diff --git a/src/parser/dice.ts b/src/parser/dice.ts index 0f735b2..8cd2adf 100644 --- a/src/parser/dice.ts +++ b/src/parser/dice.ts @@ -101,21 +101,28 @@ export class Dice { calculateHitDistribution(): DamageDistribution { const hitValues: DamageDistribution = {}; + // Hoist the per-outcome distributions out of the face loop: they are + // constant across faces, so fetching (and previously cloning) them once is + // O(faces + outcomes) instead of O(faces × outcomes). The stored maps are + // only read here, never mutated, so reading them directly is safe and + // produces identical counts. + const subtractedOutcomes: (DamageDistribution | undefined)[] = [ + this.outcomeData.crit, + this.outcomeData.missNone, + this.outcomeData.missDamage, + this.outcomeData.saveHalf, + this.outcomeData.saveFail, + this.outcomeData.pc, + ]; + for (const [face, totalCount] of Object.entries(this.faces)) { const numFace = Number(face); let hitCount = totalCount; - for (const outcomeType of [ - "crit", - "missNone", - "missDamage", - "saveHalf", - "saveFail", - "pc", - ] as Partial[]) { - const distribution = this.getOutcomeDistribution(outcomeType); - if (distribution && distribution[numFace]) { - hitCount -= distribution[numFace]; + for (const distribution of subtractedOutcomes) { + const outcomeCount = distribution?.[numFace]; + if (outcomeCount) { + hitCount -= outcomeCount; } } @@ -462,8 +469,8 @@ export class Dice { const pcDistro = this.getOutcomeDistribution("pc") || {}; let isSaveHalf = false; - for (const halfDamage of Object.keys(saveDistro).map(Number)) { - const fullDamage = halfDamage * 2; + for (const halfDamageStr in saveDistro) { + const fullDamage = Number(halfDamageStr) * 2; if (fullDamage > 0 && hitDistro[fullDamage]) { isSaveHalf = true; break; @@ -474,8 +481,9 @@ export class Dice { const clampNonNeg = (x: number) => (x < 0 && x > -1e-15 ? 0 : x); - // Process each face value - for (const [faceStr, faceCountRaw] of Object.entries(this.getFaceMap())) { + // Process each face value (iterate the internal map directly; this loop + // only reads it, so the defensive clone from getFaceMap() is unnecessary) + for (const [faceStr, faceCountRaw] of Object.entries(this.faces)) { const face = Number(faceStr); const faceCount = Number(faceCountRaw); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index b8f4c1b..a540d11 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -271,7 +271,10 @@ function multiplyDiceByDice(d1: Dice | number, d2: Dice | number): Dice { if (typeof d2 === "number") d2 = Dice.scalar(d2); const result = new Dice(); - const faces: Record = {}; + // Keyed by face value. A Map avoids the number→string→parseFloat round-trip of + // an object and preserves insertion order, which matches d1.keys() ascending + // order — so the combine order below is identical to the previous version. + const faces = new Map(); let normalizationFactor = 1; for (const key of d1.keys()) { @@ -290,12 +293,10 @@ function multiplyDiceByDice(d1: Dice | number, d2: Dice | number): Dice { } normalizationFactor *= face.total(); - faces[key] = face; + faces.set(key, face); } - for (const key of Object.keys(faces)) { - const k = parseFloat(key); // keys from object are strings - const face = faces[k]; + for (const [k, face] of faces) { const count = d1.get(k); result.combineInPlace( face.normalize((count * normalizationFactor) / face.total()) diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index ef7bc5f..2d3915f 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -22,6 +22,7 @@ export class PMF { private _mean?: number; private _variance?: number; private _stdev?: number; + private _fingerprint?: string; constructor( public readonly map: Map = new Map(), @@ -738,17 +739,22 @@ export class PMF { ): string { const [id1, id2] = [p1.identifier, p2.identifier].sort(); - // tiny fingerprints so keys change if the underlying numbers changed - const fp = (x: PMF) => { - // robust + cheap: mass + size + face sum - const m = x.mass().toFixed(12); - const n = x.map.size; - let faceSum = 0; - for (const k of x.map.keys()) faceSum += k; - return `${m}|${n}|${faceSum}`; - }; + return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${p1.fingerprint()}|${p2.fingerprint()}`; + } - return `v4:${raw ? "RAW" : "N"}:${id1}+${id2}@${eps}|${fp(p1)}|${fp(p2)}`; + /** + * A small content fingerprint (mass + bin count + face sum) so convolution + * cache keys change if the underlying numbers do. Memoized because a PMF is + * immutable once constructed — this avoids re-summing every key on each + * convolve() call (including cache hits). + */ + fingerprint(): string { + if (this._fingerprint === undefined) { + let faceSum = 0; + for (const k of this.map.keys()) faceSum += k; + this._fingerprint = `${this.mass().toFixed(12)}|${this.map.size}|${faceSum}`; + } + return this._fingerprint; } convolve(other: PMF, eps?: number, raw = false): PMF { diff --git a/src/pmf/query.ts b/src/pmf/query.ts index da2452e..c5622ad 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -754,12 +754,6 @@ export class DiceQuery { ): { labels: number[]; datasets: Array<{ label: string; data: number[] }> } { const damageValues = this.combined.support(); - // Precompute totalCount per bin for the labels you intend to stack for efficiency. - damageValues.map((dmg) => { - const bin = this.combined.map.get(dmg)!; - return labels.reduce((sum, lab) => sum + (bin.count[lab] || 0), 0); - }); - const datasets = labels.map((outcomeLabel) => ({ label: outcomeLabel, data: damageValues.map((dmg) => { From cf440919ffdf83a48f7735be631167cf4bc0f6c6 Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 13:01:02 -0700 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20math=20correctness=20=E2=80=94=20m?= =?UTF-8?q?oments,=20convolution=20NaN,=20counting=20queries,=20hd=20rerol?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent correctness audit (domain experts + adversarial verifiers, each building independent brute-force ground truth) surfaced these confirmed bugs. All verified against brute-force enumeration; 1102 tests pass. Moments (regressions from the earlier closed-form change): - variance()/stddev() used the unstable E[X²]−E[X]² form, losing accuracy by catastrophic cancellation with a large constant damage offset (1d6+1e8 gave variance 2.0 instead of 2.9167). Now use the centered, additive-per-single form (true value at every scale). - mean()/variance() ignored an explicitly-passed `combined` that diverges from convolve(singles), disagreeing with cdf/percentiles. Now fall back to the provided combined for consistency. Convolution: - Non-raw convolve() divided by zero on a zero-mass operand (scaleMass(1/0)), poisoning every bin to NaN and corrupting DiceQuery.combined.mean()/variance(). Now guarded (mGot !== 0); a zero-mass convolution stays mass 0, matching the raw path. Counting queries: - probabilityOf(label) summed full bin.p for any bin merely containing the label, badly over-counting when bins hold multiple outcomes (P(crit)=0.49 vs 0.05). It now computes the correct Poisson-binomial marginal (= probAtLeastOne). - probExactlyK([labels], k) array-path delegated to the buggy probabilityOf, so it disagreed with the (correct) string-path; now both agree with the true binomial. - singleProb divides by the single's mass, so probAtLeastOne is mass-invariant. - probAtLeastOne clamps to [0,1] (was returning 1.0000000002). - firstSuccessWeights throws on pSpecial > pSuccess instead of returning out-of-range probabilities. Parser: - hd6/hd20 (reroll-one shorthand) used a weighted union (deleteFace(1).combine), giving P(1)=1/(2s−1); now uses reroll(1) for the correct P(1)=1/s². parse("2hd6") now matches the builder's roll(2).d(6).reroll(1) exactly. Co-Authored-By: Claude Opus 4.8 --- src/parser/parser.ts | 6 ++- src/pmf/pmf.ts | 23 +++++++++- src/pmf/query.ts | 102 ++++++++++++++++++++++++++----------------- 3 files changed, 89 insertions(+), 42 deletions(-) diff --git a/src/parser/parser.ts b/src/parser/parser.ts index a540d11..51bd040 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -428,7 +428,11 @@ function parseDice(s: string[], n: number): Dice | undefined { let result = new Dice(sides); if (rerollOne) { - result = result.deleteFace(1).combine(result); + // Reroll a rolled 1 exactly once, keeping the second roll (e.g. halfling + // luck). This is the same semantics as the `reroll 1` operator; the previous + // deleteFace(1).combine(result) computed a weighted union (P(1)=1/(2s−1)), + // not a reroll (correct P(1)=1/s²). + result = result.reroll(1); } return result; diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index 2d3915f..f5131cf 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -823,10 +823,13 @@ export class PMF { // Enforce mass invariant: mass(out) = (raw? A.mass():1) * (raw? B.mass():1) const mExp = (raw ? A.mass() : 1) * (raw ? B.mass() : 1); const mGot = result.mass(); - if (mExp !== 0 && Math.abs(mGot - mExp) > epsilon) { + // Guard mGot !== 0: a zero-mass operand convolves to the zero measure + // (mass 0). Without this guard the non-raw path would scaleMass(mExp/0) = + // scaleMass(Infinity), poisoning every bin to 0*Infinity = NaN. + if (mExp !== 0 && mGot !== 0 && Math.abs(mGot - mExp) > epsilon) { result = result.scaleMass(mExp / mGot); } - if (!raw && Math.abs(result.mass() - 1) > epsilon) + if (!raw && mGot !== 0 && Math.abs(result.mass() - 1) > epsilon) result = result.normalize(); pmfCache?.set(cacheKey, result); @@ -1156,6 +1159,22 @@ export class PMF { pSpecial: number, n: number ) { + // Preconditions: the "special" successes are a subset of all successes, so + // 0 <= pSpecial <= pSuccess <= 1. Without this guard, violating inputs + // silently produce probabilities outside [0,1]. + if ( + !Number.isFinite(pSuccess) || + !Number.isFinite(pSpecial) || + pSuccess < 0 || + pSuccess > 1 || + pSpecial < 0 || + pSpecial - pSuccess > EPS + ) { + throw new Error( + `firstSuccessWeights: require 0 <= pSpecial <= pSuccess <= 1 (got pSuccess=${pSuccess}, pSpecial=${pSpecial})` + ); + } + const pFail = 1 - pSuccess; const pFailAll = Math.pow(pFail, n); diff --git a/src/pmf/query.ts b/src/pmf/query.ts index c5622ad..b193675 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -16,6 +16,7 @@ import { PMF } from "./pmf"; export class DiceQuery { public readonly singles: PMF[]; private readonly _eps: number; + private readonly _combinedProvided: boolean; private _combined?: PMF; private _combinedWithAttr?: PMF; @@ -25,6 +26,11 @@ export class DiceQuery { throw new Error("DiceQuery contains undefined singles"); } this._eps = eps; + // When the caller supplies an explicit combined distribution that may not + // equal convolve(singles), the additive closed-form moments would describe + // a different distribution than cdf/percentiles. Track this so mean()/ + // variance() can fall back to the provided combined and stay consistent. + this._combinedProvided = combined !== undefined; if (combined !== undefined) { this._combined = Math.abs(combined.mass() - 1) <= eps ? combined : combined.normalize(); @@ -108,6 +114,14 @@ export class DiceQuery { * Use case: "What's my average damage per round?" */ mean(): number { + // If the caller supplied a combined distribution that may diverge from + // convolve(singles), report its mean so mean() stays consistent with + // cdf/percentiles/min/max (which all read `combined`). + if (this._combinedProvided) { + let m = 0; + for (const [damageValue, bin] of this.combined) m += damageValue * bin.p; + return m; + } // Expectation is additive over independent attacks: E[Σ Xᵢ] = Σ E[Xᵢ]. // Computing it directly from the singles avoids building `combined`. let totalMean = 0; @@ -130,22 +144,38 @@ export class DiceQuery { * High variance means higher risk/reward. Lower variance means more consistent damage. */ variance(): number { + // Consistent with mean(): if a (possibly divergent) combined was provided, + // compute the variance of that distribution directly. + if (this._combinedProvided) { + const mu = this.mean(); + let v = 0; + for (const [damageValue, bin] of this.combined) { + const dev = damageValue - mu; + v += dev * dev * bin.p; + } + return v; + } // Variance is additive over independent attacks: Var[Σ Xᵢ] = Σ Var[Xᵢ]. - // Each single's variance is E[X²] − E[X]², computed from its own bins, - // so this avoids building `combined`. + // Use the centered form per single (E[(X−μ)²]) rather than E[X²]−μ², which + // suffers catastrophic cancellation when damage has a large constant offset. let totalVariance = 0; for (const single of this.singles) { const mass = single.mass(); if (mass <= 0) continue; - // As in mean(), avoid dividing by an effectively-unit mass. - const normalized = Math.abs(mass - 1) <= this._eps; - const mu = normalized ? single.mean() : single.mean() / mass; - let secondMoment = 0; - for (const [damageValue, probabilityBin] of single) { - const p = normalized ? probabilityBin.p : probabilityBin.p / mass; - secondMoment += damageValue * damageValue * p; + if (Math.abs(mass - 1) <= this._eps) { + // PMF.variance() is the centered, cached, mass-1 variance. + totalVariance += single.variance(); + } else { + // Normalized centered variance of a non-unit-mass single. + let mu = 0; + for (const [d, b] of single) mu += d * (b.p / mass); + let v = 0; + for (const [d, b] of single) { + const dev = d - mu; + v += dev * dev * (b.p / mass); + } + totalVariance += v; } - totalVariance += secondMoment - mu * mu; } return totalVariance; } @@ -270,11 +300,16 @@ export class DiceQuery { } private singleProb(diceIndex: number, label: OutcomeType): number { + const single = this.singles[diceIndex]; let probabilitySum = 0; - for (const [, probabilityBin] of this.singles[diceIndex]) { + for (const [, probabilityBin] of single) { probabilitySum += probabilityBin.count[label] || 0; } - return probabilitySum; + // Return the conditional per-event probability. Dividing by the single's + // mass keeps the result in [0,1] for a non-normalized single (e.g. one that + // has been scaled), so probAtLeastOne / the binomial DP stay mass-invariant. + const mass = single.mass(); + return mass > 0 ? probabilitySum / mass : 0; } probAtLeastK(labels: OutcomeType | OutcomeType[], k: number): number { @@ -318,14 +353,19 @@ export class DiceQuery { let productOfNonOccurrence = 1; for (let diceIndex = 0; diceIndex < this.singles.length; diceIndex++) { - // Calculate total probability of any of the specified labels occurring + // Calculate total probability of any of the specified labels occurring. + // Clamp to [0,1] so floating-point drift in the per-attack sum cannot push + // the complement out of range. let combinedProbability = 0; for (const label of labels) { combinedProbability += this.singleProb(diceIndex, label); } + if (combinedProbability < 0) combinedProbability = 0; + else if (combinedProbability > 1) combinedProbability = 1; productOfNonOccurrence *= 1 - combinedProbability; } - return 1 - productOfNonOccurrence; + const result = 1 - productOfNonOccurrence; + return result < 0 ? 0 : result > 1 ? 1 : result; } /** @@ -646,7 +686,8 @@ export class DiceQuery { } /** - * Returns the probability that a result includes ANY of the specified labels. + * Returns the probability that at least one attack carries ANY of the + * specified labels (the marginal P(≥1) across the independent attacks). * * Examples: * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs) @@ -655,32 +696,15 @@ export class DiceQuery { * Use cases: * - "What's the chance my resolution includes a success label?" * - "How likely am I to get any hits or crits across all attacks?" + * + * Note: this must NOT be computed by summing `combined` bin probabilities. A + * single combined damage total is reachable by many outcome combinations and + * a bin can hold several labels at once, so summing `bin.p` over bins that + * contain a label over-counts. The correct marginal is the Poisson-binomial + * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}. */ probabilityOf(labels: OutcomeType | OutcomeType[]): number { - // Handle single label case (backward compatibility) - if (typeof labels === "string") { - labels = [labels]; - } - - let totalProbability = 0; - for (const [, probabilityBin] of this.combined) { - // Calculate probability that this bin contains ANY of the specified labels - // Use inclusion-exclusion principle to avoid double-counting overlaps - let binHasAnyLabel = false; - for (const label of labels) { - if ( - probabilityBin.count[label] && - (probabilityBin.count[label] as number) > 0 - ) { - binHasAnyLabel = true; - break; - } - } - if (binHasAnyLabel) { - totalProbability += probabilityBin.p; - } - } - return totalProbability; + return this.probAtLeastOne(labels); } /** From 342cafe39880ef4f1369f1653f225d395b17c8a1 Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 13:07:58 -0700 Subject: [PATCH 07/10] test: brute-force correctness lock-down suite + document known limitations Add tests/math-correctness.test.ts (35 tests): every expectation is checked against an independent brute-force enumeration or hand-derived closed form, covering convolution/mass invariants, moment additivity & stability, Poisson-binomial counting queries (string vs array paths), parser D&D mechanics (advantage/disadvantage/elven accuracy, crit, hd reroll, keep-highest pools, save-for-half), mixtures, and the firstSuccessWeights guard. Also pins single-attack conditional-stat correctness. Tighten the previously-loosened hd tests in string-damage.test.ts from toBeCloseTo(.,0) to exact now that the parser matches the builder's reroll(1). Document the remaining audit findings as KNOWN LIMITATIONS (code comments + CHANGELOG), pinned by tests, since they need API/architecture decisions: - parser crit probability is wrong with bonus to-hit dice (builder is correct); - multi-attack conditional stats (snapshot/damageStatsFrom/outcomeTotals) are size-biased expected counts, not probabilities; - parser save-for-half can mislabel outcomes on odd constant damage; - mixN/gate/branch build O(2^n) identifier strings on deep chains. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 54 ++++ src/builder/string-damage.test.ts | 15 +- src/parser/parser.ts | 9 + src/pmf/query.ts | 13 + tests/math-correctness.test.ts | 429 ++++++++++++++++++++++++++++++ 5 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 tests/math-correctness.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 990884e..e5da505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.3.0] +### Fixed (mathematical correctness) + +Found via a multi-agent correctness audit; every fix is verified against an +independent brute-force enumeration (see `tests/math-correctness.test.ts`). + +- **`DiceQuery.probabilityOf(label)` over-counted.** It summed the full `bin.p` + of every combined bin that merely *contained* a label, but bins hold multiple + mutually-exclusive outcomes — so `probabilityOf('crit')` returned 0.49 where + the true P(crit)=0.05. It now returns the correct Poisson-binomial marginal + (= `probAtLeastOne`). `missChance()` is fixed by the same change. +- **`DiceQuery.probExactlyK([labels], k)` array-path** delegated to the buggy + `probabilityOf`, disagreeing with the (correct) single-label string path; both + now match the true binomial. +- **`DiceQuery.variance()/stddev()`** used the unstable `E[X²]−E[X]²` form and + lost all precision under a large constant damage offset (`1d6 + 1e8` gave + variance 2 instead of 35/12). Now uses the centered, additive-per-single form. +- **`DiceQuery.mean()/variance()`** now stay consistent with an explicitly + supplied `combined` that diverges from `convolve(singles)`. +- **`PMF.convolve()` produced `NaN`** for a zero-mass operand (divide-by-zero in + the mass rescale), silently poisoning `DiceQuery.combined`. A zero-mass + convolution now correctly yields mass 0. +- **`probAtLeastOne` is now mass-invariant** (per-attack probability divided by + the single's mass) and clamped to `[0,1]` (was returning `1.0000000002`). +- **`PMF.firstSuccessWeights`** throws on `pSpecial > pSuccess` instead of + returning out-of-range probabilities. +- **Parser `hd6`/`hd20` (reroll-one)** used a weighted union giving + `P(1)=1/(2s−1)`; now uses `reroll(1)` for the correct `P(1)=1/s²`. The parser + `hd` distribution now matches the builder's `reroll(1)` exactly (the + previously loosened tests are tightened). + +### Known limitations (documented; recommend maintainer review) + +These are real but require API/architecture decisions, so they are documented +and pinned by tests rather than changed blindly: + +- **Parser crit probability with bonus to-hit dice is wrong.** With bonus dice in + the to-hit (e.g. Bless, `d20 + 5 + 1d4`), the string parser collapses crit to + `1/(20·∏bonusSides)` (and the DPR is off by a few %), because the natural-20 + slice can't be separated after the bonus dice are convolved. **The builder API + computes it correctly** — use `d20.plus(..).plus(bonusDie).ac(..).onCrit(..)`. +- **Multi-attack conditional statistics are size-biased.** `snapshot()`, + `damageStatsFrom()` (single label), `outcomeTotals()` and + `outcomeDamageRanges()` aggregate the combined PMF's `count`, which is an + *expected count* for N≥2 attacks — so probabilities can exceed 1 and the + conditional `avg` is size-biased. They are correct for a single attack / + aggregate PMF. Use `probAtLeastOne` / `probExactlyK` for per-attack marginals. +- **Parser save-for-half mislabels outcomes** for some odd/low constant-damage + expressions (the `2·half ∈ hit` auto-detection heuristic false-negatives). + Damage values and the overall mean are unaffected; only the `saveHalf` / + `saveFail` / `hit` labels are mixed up. +- **`PMF.mixN`/`gate`/`branch` build O(2ⁿ) identifier strings**, which can blow up + (multi-MB, eventual `RangeError`) for very deep (≈20+) gate chains. Prefer + `PMF.exclusive`/`PMF.mix` for large mixtures. + ### Breaking - **`PMF.toJSON()` now returns a plain object** (`{ bins, normalized, identifier }`) diff --git a/src/builder/string-damage.test.ts b/src/builder/string-damage.test.ts index 9c38bcc..a939375 100644 --- a/src/builder/string-damage.test.ts +++ b/src/builder/string-damage.test.ts @@ -78,15 +78,14 @@ describe("String Damage Expressions", () => { expect(stringRes.weights.crit).toBeCloseTo(builderRes.weights.crit, 10); expect(stringRes.weights.miss).toBeCloseTo(builderRes.weights.miss, 10); - // Check damage distributions match - // Note: parser's hd syntax uses different algorithm than builder's reroll(1), - // so we expect slight differences in the distributions - expect(stringRes.hit.mean()).toBeCloseTo(builderRes.hit.mean(), 0); + // The parser's hd syntax now uses the same reroll(1) semantics as the + // builder, so the distributions match exactly. + expect(stringRes.hit.mean()).toBeCloseTo(builderRes.hit.mean(), 10); expect(stringRes.hit.min()).toBe(builderRes.hit.min()); expect(stringRes.hit.max()).toBe(builderRes.hit.max()); // Check overall PMF mean - expect(stringRes.pmf.mean()).toBeCloseTo(builderRes.pmf.mean(), 0); + expect(stringRes.pmf.mean()).toBeCloseTo(builderRes.pmf.mean(), 10); }); it("should parse '1hd12+3' with explicit crit and match roll(1).d(12).reroll(1).plus(3)", () => { @@ -103,9 +102,9 @@ describe("String Damage Expressions", () => { const stringRes = stringAttack.resolve(); const builderRes = builderAttack.resolve(); - // Note: parser's hd syntax uses different algorithm than builder's reroll(1) - expect(stringRes.pmf.mean()).toBeCloseTo(builderRes.pmf.mean(), 0); - expect(stringRes.hit.mean()).toBeCloseTo(builderRes.hit.mean(), 0); + // Parser hd now matches builder reroll(1) exactly. + expect(stringRes.pmf.mean()).toBeCloseTo(builderRes.pmf.mean(), 10); + expect(stringRes.hit.mean()).toBeCloseTo(builderRes.hit.mean(), 10); }); }); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 51bd040..6460217 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -107,6 +107,15 @@ function parseExpression(arr: string[], n: number): Dice { const arg = !op.unary ? parseArgument(arr, n) : finalResult; // Handle crit (e.g. xcrit, crit) + // + // KNOWN LIMITATION: this peels the maximum FACE of the (already convolved) + // to-hit distribution to represent the natural 20. That is correct only when + // the to-hit has no bonus dice. With bonus dice in the to-hit (e.g. Bless, + // "d20 + 5 + 1d4"), a natural 20 is "d20 == 20 with any bonus", whose mass is + // smeared across several total values that also contain non-crit mass, so the + // d20 identity is lost and the crit slice collapses to 1/(20·∏bonusSides). + // Use the builder API (d20.plus(...).plus(bonusDie).ac(...).onCrit(...)) for + // a correct crit probability with bonus to-hit dice. See CHANGELOG. let crit: Dice | undefined; let critNorm = 1; if (arr[0] === "x" || arr[0] === "c") { diff --git a/src/pmf/query.ts b/src/pmf/query.ts index b193675..a784d6d 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -554,6 +554,12 @@ export class DiceQuery { * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats. * + * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an + * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and + * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than + * E[dmg | the label occurs]. For a single attack both are the plain + * conditional figures. Use {@link probAtLeastOne} for the scenario probability. + * * @example * // High-level tactical planning * const successStats = query.damageStatsFrom('hit') @@ -1385,6 +1391,13 @@ export class DiceQuery { * Snapshot of the distribution in the exact shape the UI consumes. * - outcome probabilities are "at least one" (and equal to "all" for a single PMF) * - damageRange is conditional on the outcome occurring + * + * KNOWN LIMITATION (multi-attack): the per-outcome figures are aggregated from + * the combined PMF's `count`, which the convolution accumulates as an EXPECTED + * COUNT (E[#attacks with the label]). For a single attack these equal the + * probabilities, but for N≥2 attacks `atLeastOneProbability` can exceed 1 and + * `damageRange.avg` is size-biased (E[dmg·#label]/E[#label]). For correct + * per-attack marginals use {@link probAtLeastOne} / {@link probExactlyK}. */ snapshot(order?: readonly OutcomeType[]): Snapshot { // 1) Discover which outcomes actually appear in this PMF diff --git a/tests/math-correctness.test.ts b/tests/math-correctness.test.ts new file mode 100644 index 0000000..c71cc2c --- /dev/null +++ b/tests/math-correctness.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it } from "vitest"; +import { DiceQuery, EPS, parse, PMF } from "../src/index"; +import { d20, d4, roll } from "../src/builder/index"; + +/** + * Mathematical-correctness lock-down suite. + * + * Each expectation is checked against an INDEPENDENT brute-force reference + * (full enumeration of the sample space) or a hand-derived closed form — never + * against the engine's own output. This both confirms the bugs fixed in the + * correctness pass and guards the invariants that must always hold. + */ + +// --------------------------------------------------------------------------- +// Independent brute-force reference (no engine code) +// --------------------------------------------------------------------------- +type Dist = Map; // value -> probability + +function diceDist(count: number, sides: number, flat = 0): Dist { + let cur: Dist = new Map([[flat, 1]]); + for (let i = 0; i < count; i++) { + const next: Dist = new Map(); + for (const [v, p] of cur) + for (let f = 1; f <= sides; f++) + next.set(v + f, (next.get(v + f) ?? 0) + p / sides); + cur = next; + } + return cur; +} +function scaleProb(a: Dist, w: number): Dist { + const o: Dist = new Map(); + for (const [k, v] of a) o.set(k, v * w); + return o; +} +function mixDist(parts: Dist[]): Dist { + const o: Dist = new Map(); + for (const d of parts) for (const [k, v] of d) o.set(k, (o.get(k) ?? 0) + v); + return o; +} +function d20faces(mode: "flat" | "adv" | "dis" | "elven"): Dist { + const o: Dist = new Map(); + if (mode === "flat") { + for (let r = 1; r <= 20; r++) o.set(r, 1 / 20); + return o; + } + if (mode === "elven") { + for (let a = 1; a <= 20; a++) + for (let b = 1; b <= 20; b++) + for (let c = 1; c <= 20; c++) { + const r = Math.max(a, b, c); + o.set(r, (o.get(r) ?? 0) + 1 / 8000); + } + return o; + } + for (let a = 1; a <= 20; a++) + for (let b = 1; b <= 20; b++) { + const r = mode === "adv" ? Math.max(a, b) : Math.min(a, b); + o.set(r, (o.get(r) ?? 0) + 1 / 400); + } + return o; +} +function attack( + mode: "flat" | "adv" | "dis" | "elven", + mod: number, + ac: number, + normalDmg: Dist, + critDmg: Dist +) { + const faces = d20faces(mode); + const parts: Dist[] = []; + let pHit = 0, + pCrit = 0, + pMiss = 0; + for (const [r, p] of faces) { + if (r === 1) { + pMiss += p; + parts.push(scaleProb(new Map([[0, 1]]), p)); + } else if (r === 20) { + pCrit += p; + parts.push(scaleProb(critDmg, p)); + } else if (r + mod >= ac) { + pHit += p; + parts.push(scaleProb(normalDmg, p)); + } else { + pMiss += p; + parts.push(scaleProb(new Map([[0, 1]]), p)); + } + } + return { dist: mixDist(parts), pHit, pCrit, pMiss }; +} +const meanOf = (d: Dist) => { + let m = 0; + for (const [k, v] of d) m += k * v; + return m; +}; +const varOf = (d: Dist) => { + const mu = meanOf(d); + let v = 0; + for (const [k, p] of d) v += (k - mu) * (k - mu) * p; + return v; +}; +const massOf = (d: Dist) => { + let s = 0; + for (const v of d.values()) s += v; + return s; +}; + +// --------------------------------------------------------------------------- + +describe("Convolution & mass invariants", () => { + it("sum of independent dice matches full enumeration bin-by-bin", () => { + for (const [c, s] of [ + [2, 6], + [3, 4], + [2, 8], + ] as const) { + const ref = diceDist(c, s); + const pmf = parse(`${c}d${s}`).normalize(); + expect(pmf.mass()).toBeCloseTo(1, 12); + for (const [v, p] of ref) expect(pmf.pAt(v)).toBeCloseTo(p, 12); + } + }); + + it("a zero-mass operand convolves to mass 0, never NaN", () => { + const d6 = parse("1d6"); + const c = d6.convolve(PMF.emptyMass()); + expect(Number.isNaN(c.mass())).toBe(false); + expect(c.mass()).toBe(0); + for (const [, bin] of c) expect(bin.p).toBe(0); + + // raw and non-raw paths now agree + expect(d6.combineRaw(PMF.emptyMass()).mass()).toBe(0); + + // and it does not poison a DiceQuery + const q = new DiceQuery([d6, PMF.emptyMass()]); + expect(Number.isNaN(q.combined.mass())).toBe(false); + expect(Number.isNaN(q.mean())).toBe(false); + }); + + it("convolveMany with a zeroed middle operand and power() stay finite", () => { + const d6 = parse("1d6"); + expect(PMF.convolveMany([d6, d6.scaleMass(0), d6]).mass()).toBe(0); + expect(PMF.emptyMass().power(3).mass()).toBe(0); + }); +}); + +describe("Moments (additivity, stability, consistency)", () => { + it("mean & variance match brute force for a realistic attack", () => { + const ref = attack("flat", 5, 15, diceDist(1, 8, 3), diceDist(2, 8, 3)); + const q = parse("(d20 + 5 AC 15) * (1d8 + 3) crit (2d8 + 3)").query(); + expect(q.mean()).toBeCloseTo(meanOf(ref.dist), 9); + expect(q.variance()).toBeCloseTo(varOf(ref.dist), 6); + expect(q.stddev()).toBeCloseTo(Math.sqrt(varOf(ref.dist)), 6); + }); + + it("variance is shift-invariant (no catastrophic cancellation at scale)", () => { + // Var(1d6 + K) = Var(1d6) = 35/12 for every offset K. + for (const K of [0, 1e3, 1e6, 1e8, 1e10]) { + expect(parse(`1d6 + ${K}`).query().variance()).toBeCloseTo(35 / 12, 9); + } + }); + + it("mean/variance stay consistent with an explicitly-provided combined", () => { + const provided = PMF.fromMap(new Map([[100, 1]])); + const q = new DiceQuery([parse("1d6")], provided); // combined != convolve(singles) + expect(q.mean()).toBe(q.combined.mean()); + expect(q.mean()).toBe(100); + expect(q.variance()).toBe(q.combined.variance()); + }); + + it("mean is additive across independent attacks", () => { + const single = parse("(d20 + 7 AC 16) * (2d6 + 4) crit (4d6 + 4)"); + const one = new DiceQuery([single]).mean(); + for (const n of [1, 2, 3, 4]) { + expect(new DiceQuery(single.replicate(n)).mean()).toBeCloseTo(one * n, 9); + } + }); +}); + +describe("Counting queries (Poisson-binomial)", () => { + const single = parse("(d20 + 5 AC 15) * (1d8 + 3) crit (2d8 + 3)"); + // per-attack success probabilities (from the brute-force attack model) + const ref = attack("flat", 5, 15, diceDist(1, 8, 3), diceDist(2, 8, 3)); + const pCrit = ref.pCrit; // 0.05 + const pSucc = ref.pHit + ref.pCrit; // hit or crit + + const binom = (n: number, k: number, p: number) => { + const c = (a: number, b: number) => { + let r = 1; + for (let i = 0; i < b; i++) r = (r * (a - i)) / (i + 1); + return r; + }; + return c(n, k) * p ** k * (1 - p) ** (n - k); + }; + + it("probExactlyK string-path and array-path agree and match the true binomial", () => { + const q = new DiceQuery(single.replicate(3)); + for (let k = 0; k <= 3; k++) { + const truth = binom(3, k, pCrit); + expect(q.probExactlyK("crit", k)).toBeCloseTo(truth, 9); + expect(q.probExactlyK(["crit"], k)).toBeCloseTo(truth, 9); // array path (was badly wrong) + } + }); + + it("probAtLeastOne / probAtLeastK / probAtMostK match enumeration", () => { + const q = new DiceQuery(single.replicate(3)); + expect(q.probAtLeastOne(["hit", "crit"])).toBeCloseTo( + 1 - (1 - pSucc) ** 3, + 9 + ); + expect(q.probAtLeastK("crit", 1)).toBeCloseTo(1 - (1 - pCrit) ** 3, 9); + expect(q.probAtMostK("crit", 0)).toBeCloseTo((1 - pCrit) ** 3, 9); + }); + + it("probabilityOf equals the marginal P(>=1), not an over-count of bins", () => { + const q = new DiceQuery(single.replicate(2)); + // P(>=1 crit) over 2 attacks = 1 - 0.95^2 = 0.0975 (NOT the bin-mass sum) + expect(q.probabilityOf("crit")).toBeCloseTo(1 - (1 - pCrit) ** 2, 9); + expect(q.probabilityOf("crit")).toBeCloseTo(q.probAtLeastOne("crit"), 12); + }); + + it("single-attack probabilityOf('crit') is exactly P(nat 20) = 0.05", () => { + expect(new DiceQuery([single]).probabilityOf("crit")).toBeCloseTo(0.05, 9); + }); + + it("missChance matches P(miss) by enumeration", () => { + // single attack d20+5 vs AC15: miss on nat1 or total<15 -> 9/20 = 0.45 + const q = parse("(d20 + 5 AC 15) * (1d6)").query(); + expect(q.missChance()).toBeCloseTo(0.45, 9); + }); + + it("counting queries are invariant to single mass scaling", () => { + const q1 = new DiceQuery([single, single]); + const q2 = new DiceQuery([single.scaleMass(0.5), single.scaleMass(0.5)]); + expect(q2.probAtLeastOne("crit")).toBeCloseTo(q1.probAtLeastOne("crit"), 9); + expect(q2.probExactlyK("crit", 1)).toBeCloseTo(q1.probExactlyK("crit", 1), 9); + }); +}); + +describe("Parser D&D mechanics vs brute force", () => { + it("flat attack: mean, hit and crit probabilities", () => { + const ref = attack("flat", 6, 15, diceDist(2, 6, 4), diceDist(4, 6, 4)); + const q = parse("(d20 + 6 AC 15) * (2d6 + 4) crit (4d6 + 4)").query(); + expect(q.mean()).toBeCloseTo(meanOf(ref.dist), 9); + expect(q.probAtLeastOne("crit")).toBeCloseTo(ref.pCrit, 9); // 0.05 + expect(q.probAtLeastOne("hit")).toBeCloseTo(ref.pHit, 9); + }); + + it("advantage and disadvantage hit probabilities", () => { + const adv = attack("adv", 7, 16, diceDist(1, 8, 3), diceDist(2, 8, 3)); + const qa = parse("(d20 > d20 + 7 AC 16) * (1d8 + 3) crit (2d8 + 3)").query(); + expect(qa.mean()).toBeCloseTo(meanOf(adv.dist), 9); + expect(qa.probAtLeastOne("hit")).toBeCloseTo(adv.pHit, 9); + + const dis = attack("dis", 7, 16, diceDist(1, 8, 3), diceDist(2, 8, 3)); + const qd = parse("(d20 < d20 + 7 AC 16) * (1d8 + 3) crit (2d8 + 3)").query(); + expect(qd.mean()).toBeCloseTo(meanOf(dis.dist), 9); + expect(qd.probAtLeastOne("hit")).toBeCloseTo(dis.pHit, 9); + }); + + it("elven accuracy (triple advantage) crit probability", () => { + // P(crit) = 1 - P(no 20 in 3 rolls) = 1 - (19/20)^3 + const q = parse("(d20 > d20 > d20 + 7 AC 16) * (1d8) crit (2d8)").query(); + expect(q.probAtLeastOne("crit")).toBeCloseTo(1 - (19 / 20) ** 3, 9); + }); + + it("hd (reroll-one) shorthand: P(1)=1/s^2 and matches reroll(1)", () => { + expect(parse("hd6").pAt(1)).toBeCloseTo(1 / 36, 12); + expect(parse("hd6").mean()).toBeCloseTo(parse("d6 reroll 1").mean(), 12); + expect(parse("hd20").pAt(1)).toBeCloseTo(1 / 400, 12); + expect(parse("hd20").mean()).toBeCloseTo(10.975, 9); + // string hd matches the builder reroll(1) + expect(parse("2hd6").mean()).toBeCloseTo( + roll(2).d(6).reroll(1).pmf.mean(), + 12 + ); + }); + + it("keep-highest pool 4kh3d6 matches enumeration mean", () => { + // mean of sum of top 3 of 4d6 = 12.2446... + let total = 0; + for (let a = 1; a <= 6; a++) + for (let b = 1; b <= 6; b++) + for (let c = 1; c <= 6; c++) + for (let d = 1; d <= 6; d++) { + const xs = [a, b, c, d].sort((x, y) => y - x); + total += xs[0] + xs[1] + xs[2]; + } + const refMean = total / 6 ** 4; + expect(parse("4kh3d6").mean()).toBeCloseTo(refMean, 9); + }); + + it("save-for-half: mean and (full vs half) split by enumeration", () => { + // (d20 DC 15) * 8d6 save half: fail (roll<15) -> full 8d6; success -> floor(8d6/2) + const full = diceDist(8, 6); + const half: Dist = new Map(); + for (const [k, v] of full) + half.set(Math.floor(k / 2), (half.get(Math.floor(k / 2)) ?? 0) + v); + let pFail = 0, + pSucc = 0; + for (let r = 1; r <= 20; r++) (r >= 15 ? (pSucc += 1 / 20) : (pFail += 1 / 20)); + const ref = mixDist([scaleProb(full, pFail), scaleProb(half, pSucc)]); + const q = parse("(d20 + 0 DC 15) * 8d6 save half").query(); + expect(q.mean()).toBeCloseTo(meanOf(ref), 7); + expect(q.probAtLeastOne("saveFail")).toBeCloseTo(pFail, 6); + expect(q.probAtLeastOne("saveHalf")).toBeCloseTo(pSucc, 6); + }); +}); + +describe("Mixtures & guards", () => { + it("PMF.exclusive produces the exact weighted mixture and mass 1", () => { + const a = PMF.delta(2); + const b = PMF.delta(5); + const mix = PMF.exclusive([ + [a, 0.3], + [b, 0.7], + ]); + expect(mix.mass()).toBeCloseTo(1, 12); + expect(mix.pAt(2)).toBeCloseTo(0.3, 12); + expect(mix.pAt(5)).toBeCloseTo(0.7, 12); + }); + + it("PMF.exclusive sends leftover weight to zero", () => { + const mix = PMF.exclusive([[PMF.delta(5), 0.6]]); + expect(mix.mass()).toBeCloseTo(1, 12); + expect(mix.pAt(0)).toBeCloseTo(0.4, 12); + expect(mix.pAt(5)).toBeCloseTo(0.6, 12); + }); + + it("branch is a proper Bernoulli mixture", () => { + const out = PMF.branch(PMF.delta(10), PMF.delta(0), 0.25); + expect(out.pAt(10)).toBeCloseTo(0.25, 12); + expect(out.pAt(0)).toBeCloseTo(0.75, 12); + }); + + it("firstSuccessWeights: valid inputs sum correctly, invalid inputs throw", () => { + const w = PMF.firstSuccessWeights(0.5, 0.05, 3); + expect(w.pSpecificSuccess + w.pGeneralSuccess + w.pNone).toBeCloseTo(1, 12); + expect(w.pAny).toBeCloseTo(1 - 0.5 ** 3, 12); + expect(() => PMF.firstSuccessWeights(0.3, 0.5, 2)).toThrow(); // pSpecial > pSuccess + }); +}); + +describe("Distribution invariants hold across the corpus", () => { + const exprs = [ + "(d20 + 6 AC 15) * (2d6 + 4)", + "(d20 + 6 AC 15) * (2d6 + 4) crit (4d6 + 4)", + "(d20 > d20 + 7 AC 16) * (1d8 + 3) crit (2d8 + 3)", + "(d20 + 0 DC 15) * 8d6 save half", + "4kh3d6", + "hd20", + ]; + for (const e of exprs) { + it(`${e}: mass 1, monotone CDF, probabilities in [0,1]`, () => { + const q = parse(e).query(); + expect(q.combined.mass()).toBeCloseTo(1, 9); + let prev = -1; + for (const x of q.combined.support()) { + const c = q.cdf(x); + expect(c).toBeGreaterThanOrEqual(prev - 1e-9); + expect(c).toBeLessThanOrEqual(1 + 1e-9); + prev = c; + } + expect(q.cdf(q.max())).toBeCloseTo(1, 9); + }); + } +}); + +describe("KNOWN LIMITATION: parser crit probability with bonus to-hit dice", () => { + // The string parser cannot separate the natural-20 slice once bonus dice are + // convolved into the to-hit (the d20 identity is lost), so crit probability + // collapses to 1/(20·∏bonusSides). The BUILDER API computes it correctly. + // This test locks the correct builder behavior and pins the known parser gap + // so any change to either is caught. See CHANGELOG "Known limitations". + it("builder computes crit = 0.05 with a +1d4 (bless) to-hit", () => { + const b = d20 + .plus(5) + .plus(d4) + .ac(15) + .onHit(roll(1, 8).plus(3)) + .onCrit(roll(2, 8).plus(3)); + expect(b.resolve().weights.crit).toBeCloseTo(0.05, 9); + }); + + it("parser without bonus to-hit dice is correct (crit = 0.05)", () => { + const q = parse("(d20 + 5 AC 15) * (1d8 + 3) crit (2d8 + 3)").query(); + expect(q.probAtLeastOne("crit")).toBeCloseTo(0.05, 9); + }); + + it("parser WITH bonus to-hit dice is currently wrong (pinned)", () => { + // KNOWN BUG: should be 0.05 but the parser returns 1/80 = 0.0125. + // Pinned so the discrepancy is tracked; update when the parser is fixed. + const q = parse("(d20 + 5 + 1d4 AC 15) * (1d8 + 3) crit (2d8 + 3)").query(); + expect(q.probAtLeastOne("crit")).toBeCloseTo(0.0125, 6); + }); +}); + +describe("Conditional statistics: single-attack correctness + multi-attack caveat", () => { + const single = parse("(d20 + 5 AC 15) * (1d6) crit (2d6)"); + + it("single-attack damageStatsFrom is the true conditional expectation", () => { + // hit damage is 1d6 -> E=3.5, range 1..6, count = P(hit) = 0.5 + const hit = new DiceQuery([single]).damageStatsFrom("hit"); + expect(hit.avg).toBeCloseTo(3.5, 9); + expect(hit.min).toBe(1); + expect(hit.max).toBe(6); + expect(hit.count).toBeCloseTo(0.5, 9); + }); + + it("single-attack snapshot probabilities are valid (<= 1)", () => { + const snap = new DiceQuery([single]).snapshot(["hit", "crit", "missNone"]); + expect(snap.outcomes.get("hit")!.atLeastOneProbability).toBeCloseTo(0.5, 9); + expect(snap.outcomes.get("crit")!.atLeastOneProbability).toBeCloseTo( + 0.05, + 9 + ); + }); + + it("KNOWN LIMITATION: multi-attack snapshot reports expected counts (pinned)", () => { + // For N attacks these per-outcome figures are E[#label], not probabilities, + // and can exceed 1. Use probAtLeastOne for the true marginal. Pinned so the + // documented behavior is tracked. + const q = new DiceQuery([single, single, single, single]); // 4 attacks + const snap = q.snapshot(["hit"]); + expect(snap.outcomes.get("hit")!.atLeastOneProbability).toBeCloseTo(2.0, 6); // 4 * 0.5 + // the correct per-attack marginal: + expect(q.probAtLeastOne("hit")).toBeCloseTo(1 - 0.5 ** 4, 9); // 0.9375 + }); +}); From 3314a9f01191553cb53e3c41c0e2d14ea8ff51d5 Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 13:23:51 -0700 Subject: [PATCH 08/10] fix: save-for-half labeling + snapshot probabilities (correctness pass 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more confirmed correctness bugs from the audit, verified against brute force. - Parser save-for-half mislabeled outcomes on odd/constant damage, e.g. "(d20 DC 15) * (3) save half" reported hit=0.7 / saveFail=0.3 instead of saveFail=0.7 / saveHalf=0.3. The "isSaveHalf" detection relied on a brittle heuristic (2×a-half-value appearing in the hit distribution) that false-negatived whenever full != 2*floor(full/2). It is now deterministic: a non-empty save distribution *is* the save-half signal. Damage/mean were always correct; only the saveHalf/saveFail/hit labels were wrong. - DiceQuery.snapshot() reported outcome "probabilities" aggregated from the combined PMF's count, which is an expected count for N>=2 attacks — so atLeastOneProbability could exceed 1 (e.g. 3.2 for 4 attacks). It now uses the correct Poisson-binomial marginals: atLeastOneProbability = probAtLeastOne, allProbability = probAtLeastK(label, N). Single-attack results are unchanged. (damageRange.avg remains a size-biased mean for N>=2; documented.) Not fixed (documented as known limitations, need an architectural change): - Parser crit probability with bonus to-hit dice (Bless): the crit mass depends on the d20 roll mode, which is encoded in face counts and destroyed once bonus dice are convolved in. A correct fix needs nat-20 tracking at the d20 level (what the builder AST already does). The builder API is correct; use it. Adds brute-force lock-down tests for both fixes. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 26 ++++++++++++-------- src/parser/dice.ts | 14 +++++------ src/pmf/query.ts | 25 +++++++++++--------- tests/math-correctness.test.ts | 43 +++++++++++++++++++++++++++------- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5da505..2da37ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,16 @@ independent brute-force enumeration (see `tests/math-correctness.test.ts`). `P(1)=1/(2s−1)`; now uses `reroll(1)` for the correct `P(1)=1/s²`. The parser `hd` distribution now matches the builder's `reroll(1)` exactly (the previously loosened tests are tightened). +- **`DiceQuery.snapshot()` outcome probabilities** (`atLeastOneProbability`, + `allProbability`) were aggregated as expected counts and could exceed 1 for + multi-attack queries. They now use the correct Poisson-binomial marginals + (P(≥1) and P(all)) and are always in [0,1]. (`damageRange.avg` remains a + size-biased mean for N≥2 — see Known limitations.) +- **Parser save-for-half mislabeled outcomes** on odd/constant damage (e.g. + `(d20 DC 15) * (3) save half`): the brittle "2×half ∈ hit" detection + false-negatived, tagging the success mass as `saveFail` and the failure mass + as `hit`. Detection is now deterministic (the presence of a save distribution), + so `saveHalf`/`saveFail` are always labeled correctly. ### Known limitations (documented; recommend maintainer review) @@ -47,16 +57,12 @@ and pinned by tests rather than changed blindly: `1/(20·∏bonusSides)` (and the DPR is off by a few %), because the natural-20 slice can't be separated after the bonus dice are convolved. **The builder API computes it correctly** — use `d20.plus(..).plus(bonusDie).ac(..).onCrit(..)`. -- **Multi-attack conditional statistics are size-biased.** `snapshot()`, - `damageStatsFrom()` (single label), `outcomeTotals()` and - `outcomeDamageRanges()` aggregate the combined PMF's `count`, which is an - *expected count* for N≥2 attacks — so probabilities can exceed 1 and the - conditional `avg` is size-biased. They are correct for a single attack / - aggregate PMF. Use `probAtLeastOne` / `probExactlyK` for per-attack marginals. -- **Parser save-for-half mislabels outcomes** for some odd/low constant-damage - expressions (the `2·half ∈ hit` auto-detection heuristic false-negatives). - Damage values and the overall mean are unaffected; only the `saveHalf` / - `saveFail` / `hit` labels are mixed up. +- **Multi-attack conditional damage `avg` is size-biased.** The `avg` returned by + `damageStatsFrom()` (single label), `outcomeDamageRanges()` and + `snapshot().damageRange` aggregates the combined PMF's `count` (an *expected + count* for N≥2 attacks), so it is the size-biased mean E[dmg·#label]/E[#label] + rather than a clean conditional expectation. It is correct for a single attack. + (The associated *probabilities* are now correct — see Fixed.) - **`PMF.mixN`/`gate`/`branch` build O(2ⁿ) identifier strings**, which can blow up (multi-MB, eventual `RangeError`) for very deep (≈20+) gate chains. Prefer `PMF.exclusive`/`PMF.mix` for large mixtures. diff --git a/src/parser/dice.ts b/src/parser/dice.ts index 8cd2adf..0bc194d 100644 --- a/src/parser/dice.ts +++ b/src/parser/dice.ts @@ -468,14 +468,12 @@ export class Dice { const saveDistro = this.getOutcomeDistribution("saveHalf") || {}; const pcDistro = this.getOutcomeDistribution("pc") || {}; - let isSaveHalf = false; - for (const halfDamageStr in saveDistro) { - const fullDamage = Number(halfDamageStr) * 2; - if (fullDamage > 0 && hitDistro[fullDamage]) { - isSaveHalf = true; - break; - } - } + // A non-empty save distribution means the "save" mechanic was used (e.g. + // "save half"): its success mass is a saveHalf outcome and the remaining + // (full-damage) mass is a saveFail. The previous heuristic — "isSaveHalf iff + // 2×(a half value) appears in the hit distribution" — false-negatived on odd + // or constant damage, mislabeling saveHalf as saveFail and saveFail as hit. + const isSaveHalf = Object.keys(saveDistro).length > 0; const isDCCheck = this.privateData.isDCCheck === true; diff --git a/src/pmf/query.ts b/src/pmf/query.ts index a784d6d..47ab224 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -1392,12 +1392,15 @@ export class DiceQuery { * - outcome probabilities are "at least one" (and equal to "all" for a single PMF) * - damageRange is conditional on the outcome occurring * - * KNOWN LIMITATION (multi-attack): the per-outcome figures are aggregated from + * The outcome probabilities use the correct Poisson-binomial marginals + * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)), + * so they are always valid probabilities in [0,1]. + * + * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from * the combined PMF's `count`, which the convolution accumulates as an EXPECTED - * COUNT (E[#attacks with the label]). For a single attack these equal the - * probabilities, but for N≥2 attacks `atLeastOneProbability` can exceed 1 and - * `damageRange.avg` is size-biased (E[dmg·#label]/E[#label]). For correct - * per-attack marginals use {@link probAtLeastOne} / {@link probExactlyK}. + * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label] + * rather than a clean conditional expectation. It is correct for a single + * attack. */ snapshot(order?: readonly OutcomeType[]): Snapshot { // 1) Discover which outcomes actually appear in this PMF @@ -1428,13 +1431,11 @@ export class DiceQuery { // 2) Aggregate per-outcome mass and conditional damage ranges via labeled table const rows = this.toLabeledTable(outcomes as OutcomeType[]); - const totals = new Map(); const rangeAcc = new Map< OutcomeType, { min?: number; max?: number; sum: number; mass: number } >(); for (const ot of outcomes) { - totals.set(ot as OutcomeType, 0); rangeAcc.set(ot as OutcomeType, { sum: 0, mass: 0 }); } @@ -1443,7 +1444,6 @@ export class DiceQuery { for (const ot of outcomes) { const p = (row[ot] as number) || 0; if (p <= 0) continue; - totals.set(ot as OutcomeType, (totals.get(ot as OutcomeType) || 0) + p); const r = rangeAcc.get(ot as OutcomeType)!; r.sum += dmg * p; @@ -1453,14 +1453,17 @@ export class DiceQuery { } } + const n = this.singles.length; const outcomeMap = new Map(); for (const ot of outcomes) { - const total = totals.get(ot as OutcomeType) || 0; const r = rangeAcc.get(ot as OutcomeType)!; const avg = r.mass > 0 ? r.sum / r.mass : 0; + // Use the correct per-attack marginals (Poisson-binomial) so these are + // always valid probabilities in [0,1]. For a single attack both reduce to + // P(outcome); the previous `total` was an expected count and could exceed 1. outcomeMap.set(ot as OutcomeType, { - atLeastOneProbability: total, - allProbability: total, // single aggregate PMF: same value + atLeastOneProbability: this.probAtLeastOne(ot as OutcomeType), + allProbability: this.probAtLeastK(ot as OutcomeType, n), damageRange: { min: r.min ?? 0, avg, max: r.max ?? 0 }, }); } diff --git a/tests/math-correctness.test.ts b/tests/math-correctness.test.ts index c71cc2c..f2c2f62 100644 --- a/tests/math-correctness.test.ts +++ b/tests/math-correctness.test.ts @@ -305,6 +305,26 @@ describe("Parser D&D mechanics vs brute force", () => { expect(q.probAtLeastOne("saveFail")).toBeCloseTo(pFail, 6); expect(q.probAtLeastOne("saveHalf")).toBeCloseTo(pSucc, 6); }); + + it("save-for-half labels are correct for constant / odd damage", () => { + // (d20 DC 15) * (D) save half: fail (roll<15, 0.7) -> full D; success (0.3) -> + // floor(D/2). The success mass is saveHalf, the failure mass is saveFail, and + // there is no "hit" mass. This previously mislabeled on odd/constant damage. + for (const [expr, D] of [ + ["(d20 + 0 DC 15) * (3) save half", 3], + ["(d20 + 0 DC 15) * (1d6 + 3) save half", null], + ["(d20 + 0 DC 15) * (1d4 + 1) save half", null], + ] as const) { + const q = parse(expr).query(); + expect(q.probAtLeastOne("saveFail")).toBeCloseTo(0.7, 6); + expect(q.probAtLeastOne("saveHalf")).toBeCloseTo(0.3, 6); + expect(q.probAtLeastOne("hit")).toBeCloseTo(0, 6); + if (D !== null) { + // mean = 0.7*D + 0.3*floor(D/2) + expect(q.mean()).toBeCloseTo(0.7 * D + 0.3 * Math.floor(D / 2), 9); + } + } + }); }); describe("Mixtures & guards", () => { @@ -416,14 +436,21 @@ describe("Conditional statistics: single-attack correctness + multi-attack cavea ); }); - it("KNOWN LIMITATION: multi-attack snapshot reports expected counts (pinned)", () => { - // For N attacks these per-outcome figures are E[#label], not probabilities, - // and can exceed 1. Use probAtLeastOne for the true marginal. Pinned so the - // documented behavior is tracked. - const q = new DiceQuery([single, single, single, single]); // 4 attacks + it("multi-attack snapshot reports valid Poisson-binomial probabilities", () => { + const q = new DiceQuery([single, single, single, single]); // 4 attacks, P(hit)=0.5 each const snap = q.snapshot(["hit"]); - expect(snap.outcomes.get("hit")!.atLeastOneProbability).toBeCloseTo(2.0, 6); // 4 * 0.5 - // the correct per-attack marginal: - expect(q.probAtLeastOne("hit")).toBeCloseTo(1 - 0.5 ** 4, 9); // 0.9375 + const hit = snap.outcomes.get("hit")!; + // P(>=1 hit) = 1 - 0.5^4 = 0.9375 ; P(all 4 hit) = 0.5^4 = 0.0625 + expect(hit.atLeastOneProbability).toBeCloseTo(1 - 0.5 ** 4, 9); + expect(hit.atLeastOneProbability).toBeLessThanOrEqual(1); + expect(hit.allProbability).toBeCloseTo(0.5 ** 4, 9); + }); + + it("KNOWN LIMITATION: multi-attack damageRange.avg is size-biased (pinned)", () => { + // damageRange.avg still weights by expected count (E[dmg·#label]/E[#label]), + // so it is not a clean conditional mean for N>=2. Pinned for tracking. + const q = new DiceQuery([single, single, single, single]); + const avg = q.snapshot(["hit"]).outcomes.get("hit")!.damageRange.avg; + expect(avg).toBeGreaterThan(0); // documented size-biased quantity, not E[dmg|>=1 hit] }); }); From d2506d8ba57f388c0ccd448f65de31576d29380b Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 13:36:09 -0700 Subject: [PATCH 09/10] ci: skip the Vitest test step on Node 18 (Vitest 4 requires Node >= 20) The Node 18 CI leg failed at Vitest startup: Vitest 4 imports `styleText` from node:util, which only exists in Node >= 20.12, so the test runner cannot start on Node 18. The published package itself still supports Node >= 18.17 (its ES2020 build uses no such APIs). Keep Node 18 in the matrix for typecheck/build/pack (verifying the package builds for its declared engines minimum), skip only the Vitest `Test` step there, and add Node 24 so three Node versions still run the test suite. Also set fail-fast: false so one leg failing no longer cancels the others. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a899af8..207e8fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,13 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node: [18, 20, 22] + # The published package supports Node >= 18.17, so we type-check, build + # and pack on Node 18. The test runner (Vitest 4) requires Node >= 20 + # (it imports `styleText` from node:util), so the Test step below is + # skipped on Node 18 and runs on 20/22/24. + node: [18, 20, 22, 24] steps: - name: Checkout uses: actions/checkout@v4 @@ -50,6 +55,8 @@ jobs: run: yarn build - name: Test + # Vitest 4 requires Node >= 20; Node 18 verifies build + pack only. + if: matrix.node != '18' run: yarn test - name: Pack (sanity) From 3970cb8133434f99fead2cf2151e29638245b314 Mon Sep 17 00:00:00 2001 From: Michael Margolis Date: Sat, 27 Jun 2026 17:07:34 -0700 Subject: [PATCH 10/10] Tidy hot paths, harden the parser, and fix a compaction bug Bug fix: PMF.compact() deleted sub-epsilon count/attr entries in place on bin objects that are shared by reference between PMFs, corrupting the source distribution. It now clones each surviving bin before pruning. Hardening: The parser rejects pathological expressions with a DiceParseError instead of hanging: dice over 1,000,000 faces, counts over 10,000, keep pools that would enumerate over 1,000,000 outcomes, and binary operations whose face-count product exceeds 100,000,000 (e.g. d100000 + d100000, previously a multi-second hang). Legitimate expressions, including d100000, are unaffected. Performance (behavior-preserving): Count queries compute each attack's success probability and the binomial DP once instead of per requested count; PMF.branch builds its mixture in a single pass; keepSumPMF uses integer DP state keys; computeMaxOfPMF is O(N) for large pools; reroll and binaryOp avoid repeated allocations. Also tightens Dice.outcomeData typing, adds a yarn format script, and folds the release notes into the 0.3.0 changelog entry. Adds characterization and parser guard test suites; full suite green, typecheck and lint clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 38 ++- package.json | 1 + src/builder/ast.ts | 37 ++- src/parser/dice.ts | 43 ++- src/parser/parser.ts | 31 +- src/pmf/pmf.ts | 55 ++-- src/pmf/query.ts | 68 +++-- tests/fixtures/refactor-characterization.json | 1 + tests/parser-resource-guards.test.ts | 77 +++++ tests/refactor-characterization.test.ts | 271 ++++++++++++++++++ 10 files changed, 550 insertions(+), 72 deletions(-) create mode 100644 tests/fixtures/refactor-characterization.json create mode 100644 tests/parser-resource-guards.test.ts create mode 100644 tests/refactor-characterization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da37ab..531af67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed (mathematical correctness) -Found via a multi-agent correctness audit; every fix is verified against an -independent brute-force enumeration (see `tests/math-correctness.test.ts`). +Every fix is verified against an independent brute-force enumeration (see +`tests/math-correctness.test.ts`). - **`DiceQuery.probabilityOf(label)` over-counted.** It summed the full `bin.p` of every combined bin that merely *contained* a label, but bins hold multiple @@ -46,6 +46,24 @@ independent brute-force enumeration (see `tests/math-correctness.test.ts`). false-negatived, tagging the success mass as `saveFail` and the failure mass as `hit`. Detection is now deterministic (the presence of a save distribution), so `saveHalf`/`saveFail` are always labeled correctly. +- **`PMF.compact()` corrupted PMFs that shared bin objects.** It deleted + sub-epsilon `count`/`attr` entries *in place* and reused that same bin + reference in the compacted map. Because bins are shared by reference across + PMFs (the `branch()` / `addScaled()` / `scaleMass()` fast paths can carry + another PMF's bin objects), this silently mutated the source PMF — and the + receiver's own bins. `compact()` now clones each surviving bin before pruning; + the compacted result is unchanged. + +### Security / hardening + +- **Parser resource-exhaustion guards.** Adversarial expressions are rejected + with a `DiceParseError` instead of exhausting CPU/memory: a die over 1,000,000 + faces, a dice count over 10,000, a keep whose `faces^count` enumeration would + exceed 1,000,000 outcomes, and a binary operation whose `faces₁ × faces₂` work + would exceed 100,000,000 face pairs. The last closes a gap the per-operand + caps missed — two individually-legal large dice (e.g. `d100000 + d100000`, + ~10¹⁰ operations) previously hung for tens of seconds. All legitimate + expressions, including `d100000`, still parse. ### Known limitations (documented; recommend maintainer review) @@ -119,6 +137,18 @@ and pinned by tests rather than changed blindly: whose result was discarded — ~2× faster. Bit-identical. - Minor bit-identical cleanups on the parse path (`Dice.toPMF` iterates the internal face map directly; `multiplyDiceByDice` uses a `Map`). +- **`DiceQuery` count queries (`probExactlyK` / `probAtLeastK` / `probAtMostK`, + array-label paths)** compute each attack's success probability and the binomial + DP once instead of rebuilding a query per requested count (~3× on the looped + variants). +- **`PMF.branch()` assembles its Bernoulli mixture in a single pass** rather than + chaining two `addScaled` calls (which copied the failure branch's bins twice). +- **`keepSumPMF` packs its DP state into a single integer key** instead of a + `"used|r"` string parsed on every transition. +- **`computeMaxOfPMF` walks the support once with a running CDF** for large pools, + reducing the max-of computation from O(N²) to O(N). +- **`Dice.reroll()` uses a `Set` for membership** and **`Dice.binaryOp()` hoists + the inner die's face list** out of its loop. All of the above were verified bit-for-bit identical (probabilities, counts, means, variance) across the full expression corpus. @@ -132,3 +162,7 @@ and pinned by tests rather than changed blindly: - Internal refactors with no behavioral change: deduplicated `Bin` clone/scale logic in `PMF`, removed dead code and impossible iterator branches, and tightened internal `any` usage. +- `Dice.outcomeData` is typed `Partial>` (dropping an + unsound `as Record<…>` cast); `getFullOutcomeDistribution()`'s return type + matches. Type-only change; runtime output is unchanged. +- Added a `yarn format` script (ESLint autofix). diff --git a/package.json b/package.json index 2fc3581..bf3a3f0 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "./dist/builder/dc.js" ], "scripts": { + "format": "eslint . --fix", "lint": "eslint .", "typecheck": "tsc -p config/tsconfig.build.json --noEmit", "build": "tsup --config config/tsup.config.ts", diff --git a/src/builder/ast.ts b/src/builder/ast.ts index 4feb82b..005f5dd 100644 --- a/src/builder/ast.ts +++ b/src/builder/ast.ts @@ -371,16 +371,19 @@ function computeMaxOfPMF( dfs(count, -Infinity, 1); } else { - // For larger cases, use the CDF method + // For larger cases, use the CDF method. Walk the sorted support once while + // accumulating a running CDF, so each P(max = v) costs O(1) instead of a + // full-map cdfAt() scan (previously O(N) per value → O(N²) overall). + // Between two consecutive support points there is no probability mass, so + // the running CDF up to (but not including) v equals cdfAt(v - 1). const sortedSupport = [...support].sort((a, b) => a - b); + let runningCdf = 0; for (const value of sortedSupport) { - // P(max = value) = P(all rolls <= value) - P(all rolls <= value-1) - const cdfAtValue = pmf.cdfAt(value); - const cdfAtValueMinus1 = - value > sortedSupport[0] ? pmf.cdfAt(value - 1) : 0; + const prevCdf = runningCdf; + runningCdf += pmf.pAt(value); - const probMax = - Math.pow(cdfAtValue, count) - Math.pow(cdfAtValueMinus1, count); + // P(max = value) = P(all rolls <= value) - P(all rolls <= value-1) + const probMax = Math.pow(runningCdf, count) - Math.pow(prevCdf, count); if (probMax > eps) { out.set(value, probMax); } @@ -430,8 +433,13 @@ function keepSumPMF( // Select t = min(X, keep - used) into the sum (highest picks first), then continue with r - X. type SumMap = Map; - let state: Map = new Map(); - const keyOf = (used: number, r: number) => `${used}|${r}`; + let state: Map = new Map(); + // Pack the (used, remainingTrials) state into a single integer key instead of + // a "used|r" string. r ∈ [0, total], so a stride of (total + 1) is collision + // free, and decoding is plain integer math — no split()/parseInt() per + // transition in the hot loop. Behavior is identical (same states, same order). + const stride = total + 1; + const keyOf = (used: number, r: number) => used * stride + r; state.set(keyOf(0, total), new Map([[0, 1]])); @@ -475,9 +483,9 @@ function keepSumPMF( return out.size === m.size ? m : out; }; - const pruneState = (st: Map, threshold: number) => { + const pruneState = (st: Map, threshold: number) => { if (threshold <= 0) return st; - const out = new Map(); + const out = new Map(); for (const [k, m] of st) { const mm = pruneMap(m, threshold); if (mm.size > 0) out.set(k, mm); @@ -491,12 +499,11 @@ function keepSumPMF( if (p <= 0) continue; const q = Math.max(eps, 1 - processedMass); const pCond = Math.min(1, p / q); - const next: Map = new Map(); + const next: Map = new Map(); for (const [k, m] of state) { - const [usedStr, rStr] = k.split("|"); - const used = parseInt(usedStr, 10); - const r = parseInt(rStr, 10); + const used = Math.floor(k / stride); + const r = k - used * stride; if (r === 0) { // No trials left; carry state forward unchanged const destKey = keyOf(used, 0); diff --git a/src/parser/dice.ts b/src/parser/dice.ts index 0bc194d..ea89618 100644 --- a/src/parser/dice.ts +++ b/src/parser/dice.ts @@ -1,5 +1,6 @@ // dice.ts (internal) +import { DiceParseError } from "../common/errors"; import type { Bin, DamageDistribution, @@ -9,6 +10,16 @@ import type { import { EPS } from "../common/types"; import { PMF } from "../pmf/pmf"; +/** + * Work budget for a single dice×dice operation. binaryOp is O(faces₁ × faces₂), + * so two individually-legal large dice (e.g. `d100000 + d100000`) would otherwise + * enumerate ~10^10 face pairs and hang. The parser's per-die / per-count caps + * bound each operand but not their product, so this bounds the operation itself. + * Generous enough for any realistic expression; only pathological blow-ups trip + * it. (Chosen so expressions well beyond normal use still parse; tune if needed.) + */ +const MAX_BINARY_OUTCOMES = 100_000_000; + /** Internal bookkeeping attached to a {@link Dice} during parsing. */ export interface DicePrivateData { /** Marks a DC (saving-throw) check so outcomes are attributed correctly. */ @@ -25,10 +36,10 @@ export interface DicePrivateData { export class Dice { private readonly faces: DamageDistribution = {}; public privateData: DicePrivateData = {}; - private outcomeData: Record = {} as Record< - OutcomeType, - DamageDistribution - >; + // Partial: the object starts empty and gains keys as outcomes are recorded, + // so the type must not claim every OutcomeType is present. (Previously typed + // as a full Record via an `as` cast, which lied about missing keys.) + private outcomeData: Partial> = {}; private hasHitDistributionCalculated = false; public identifier?: string; @@ -50,9 +61,8 @@ export class Dice { return { ...distribution }; } - getFullOutcomeDistribution(): Record< - OutcomeType, - DamageDistribution | undefined + getFullOutcomeDistribution(): Partial< + Record > { return { ...this.outcomeData }; } @@ -161,6 +171,18 @@ export class Dice { const isScalar = typeof other === "number"; const keys1 = this.keys(); + // Hoist the inner die's faces out of the outer loop: they are constant + // across key1, so computing them once avoids re-allocating the array on + // every outer iteration. + const keys2 = isScalar ? [] : (other as Dice).keys(); + + // Guard the O(faces₁ × faces₂) work before running it: a binary op between + // two large (but individually legal) dice would otherwise blow up. + if (!isScalar && keys1.length * keys2.length > MAX_BINARY_OUTCOMES) { + throw new DiceParseError( + `Dice operation over ${keys1.length}×${keys2.length} face pairs exceeds the maximum of ${MAX_BINARY_OUTCOMES}` + ); + } for (const key1 of keys1) { const value1 = this.faces[key1]!; @@ -169,7 +191,6 @@ export class Dice { const resultKey = op(key1, other as number); result.increment(resultKey, value1); } else { - const keys2 = other.keys(); for (const key2 of keys2) { const value2 = other.faces[key2]!; const resultKey = op(key1, key2); @@ -373,11 +394,13 @@ export class Dice { const rerollDice = typeof toReroll === "number" ? Dice.scalar(toReroll) : toReroll; - const removed = this.removeFaces(rerollDice.keys()); + const rerollKeys = rerollDice.keys(); + const rerollSet = new Set(rerollKeys); + const removed = this.removeFaces(rerollKeys); let result = new Dice(); for (const face of this.keys()) { - const wasRerolled = rerollDice.keys().includes(face); + const wasRerolled = rerollSet.has(face); result = result.combine(removed); if (wasRerolled) { result = result.combine(this); diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 6460217..8c5f17f 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -8,6 +8,18 @@ type DiceOperation = ((this: Dice, other: Dice | number) => Dice) & { unary?: boolean; }; +/** + * Resource-exhaustion guards. Adversarial expressions (a huge die, a huge dice + * count, or a keep over a large enumerated pool) can otherwise blow up memory + * and CPU. These caps are deliberately generous so every legitimate expression + * the suite exercises (e.g. d100000) still parses. + */ +// Must stay >= 100000: the suite asserts d100000 (100k faces) parses. +const MAX_DIE_SIDES = 1_000_000; +const MAX_DICE_COUNT = 10_000; +// multiplyDiceByDice enumerates faces^count outcomes when a keep is applied. +const MAX_KEEP_OUTCOMES = 1_000_000; + /** * Internal parse cache for PMFs produced from string expressions. * Keyed by cleaned expression (spaces stripped, lowercased) and optional `n` value. @@ -294,7 +306,14 @@ function multiplyDiceByDice(d1: Dice | number, d2: Dice | number): Dice { } if (d2.privateData.keep) { - // Repeat dice2 "key" times and apply keep + // Repeat dice2 "key" times and apply keep. opDice enumerates the full + // faces^count outcome space, so guard against a combinatorial blow-up. + const faceCount = d2.keys().length; + if (Math.pow(faceCount, key) > MAX_KEEP_OUTCOMES) { + throw new DiceParseError( + `Keep enumeration of ${faceCount}^${key} outcomes exceeds the maximum of ${MAX_KEEP_OUTCOMES}` + ); + } const repeat: Dice[] = Array(key).fill(d2); face = opDice(repeat, d2.privateData.keep); } else { @@ -317,6 +336,11 @@ function multiplyDiceByDice(d1: Dice | number, d2: Dice | number): Dice { } function multiplyDice(n: number, d: Dice): Dice { + if (n > MAX_DICE_COUNT) { + throw new DiceParseError( + `Dice count ${n} exceeds the maximum of ${MAX_DICE_COUNT}` + ); + } if (n === 0) return new Dice(0); if (n === 1) return d; @@ -434,6 +458,11 @@ function parseDice(s: string[], n: number): Dice | undefined { } const sides = parseNumber(s, n); + if (sides > MAX_DIE_SIDES) { + throw new DiceParseError( + `Die size ${sides} exceeds the maximum of ${MAX_DIE_SIDES}` + ); + } let result = new Dice(sides); if (rerollOne) { diff --git a/src/pmf/pmf.ts b/src/pmf/pmf.ts index f5131cf..005bb5a 100644 --- a/src/pmf/pmf.ts +++ b/src/pmf/pmf.ts @@ -78,9 +78,11 @@ export class PMF { const q = 1 - p; - // Fast paths - if (p === 0) return failurePMF.scaleMass(1); // clone - if (p === 1) return successPMF.scaleMass(1); // clone + // Fast paths. scaleMass(1) returns the same instance, so these hand back the + // branch PMF unchanged. That is safe because PMFs are treated as immutable + // (compact() and the other transforms now clone bins rather than mutate). + if (p === 0) return failurePMF.scaleMass(1); + if (p === 1) return successPMF.scaleMass(1); // Choose epsilon. You can also pick Math.min for a tighter threshold. const eps = successPMF.epsilon ?? failurePMF.epsilon; @@ -88,12 +90,21 @@ export class PMF { successPMF.identifier }*${p.toFixed(6)})`; - // Proper Bernoulli mixture: q·failure ⊕ p·success - const out = PMF.empty(eps, id) - .addScaled(failurePMF, q) - .addScaled(successPMF, p); + // Proper Bernoulli mixture: q·failure ⊕ p·success, assembled in a single + // pass. The previous `empty().addScaled(failure,q).addScaled(success,p)` + // chain copied failurePMF's bins twice (into the intermediate, then again + // when the intermediate was copied by the second addScaled). Merging both + // scaled branches directly into one fresh map keeps the same accumulation + // order — q·failure first, then p·success — so the result is bit-identical. + const resultMap = new Map(); + for (const [damageValue, bin] of failurePMF.map) { + PMF.mergeInto(resultMap, damageValue, PMF.scaleBin(bin, q)); + } + for (const [damageValue, bin] of successPMF.map) { + PMF.mergeInto(resultMap, damageValue, PMF.scaleBin(bin, p)); + } - return out; + return new PMF(resultMap, eps, false, id); } /** @@ -497,26 +508,30 @@ export class PMF { if (!shouldKeep) continue; - // Clean up count entries below epsilon (mutates the bin like your original) - for (const labelKey in probabilityBin.count) { - if (Math.abs(probabilityBin.count[labelKey] || 0) < eps) { - delete probabilityBin.count[labelKey]; + // Build a fresh Bin rather than mutating the source. Bins are shared by + // reference across PMFs (e.g. branch()/addScaled()/scaleMass() fast paths + // can carry another PMF's bin objects), so deleting sub-eps entries in + // place would silently corrupt the source PMF's count/attr. + const cleanedBin = PMF.cloneBin(probabilityBin); + + for (const labelKey in cleanedBin.count) { + if (Math.abs(cleanedBin.count[labelKey] || 0) < eps) { + delete cleanedBin.count[labelKey]; } } - // Clean up attr entries below epsilon - if (probabilityBin.attr) { - for (const labelKey in probabilityBin.attr) { - if (Math.abs(probabilityBin.attr[labelKey] || 0) < eps) { - delete probabilityBin.attr[labelKey]; + if (cleanedBin.attr) { + for (const labelKey in cleanedBin.attr) { + if (Math.abs(cleanedBin.attr[labelKey] || 0) < eps) { + delete cleanedBin.attr[labelKey]; } } - if (Object.keys(probabilityBin.attr).length === 0) { - probabilityBin.attr = undefined; + if (Object.keys(cleanedBin.attr).length === 0) { + cleanedBin.attr = undefined; } } - compactedMap.set(damageValue, probabilityBin); + compactedMap.set(damageValue, cleanedBin); } return new PMF(compactedMap, eps, this.normalized, this.identifier); diff --git a/src/pmf/query.ts b/src/pmf/query.ts index 47ab224..a5f0a5b 100644 --- a/src/pmf/query.ts +++ b/src/pmf/query.ts @@ -312,6 +312,36 @@ export class DiceQuery { return mass > 0 ? probabilitySum / mass : 0; } + /** + * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it + * carries ANY of `labels`", over the n independent singles. + * + * Each single's per-event success probability is the Poisson-binomial + * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne), + * computed exactly once. The binomial DP then runs once to produce the whole + * distribution, so the array-label paths of probExactlyK / probAtLeastK / + * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and + * re-running the DP per requested k. + */ + private countDistribution(labels: OutcomeType[]): number[] { + const n = this.singles.length; + const successProbabilities = this.singles.map((single) => + new DiceQuery([single]).probabilityOf(labels) + ); + + const dist = new Array(n + 1).fill(0); + dist[0] = 1; + for (const successProb of successProbabilities) { + for (let outcomeCount = n; outcomeCount >= 1; outcomeCount--) { + dist[outcomeCount] = + dist[outcomeCount] * (1 - successProb) + + dist[outcomeCount - 1] * successProb; + } + dist[0] *= 1 - successProb; + } + return dist; + } + probAtLeastK(labels: OutcomeType | OutcomeType[], k: number): number { const L = Array.isArray(labels) ? [...new Set(labels)] : [labels]; const n = this.singles.length; @@ -319,9 +349,12 @@ export class DiceQuery { if (k <= 0) return 1; if (k > n) return 0; + // Sum the upper tail of the single, shared count distribution rather than + // calling probExactlyK (which rebuilt the distribution) once per i. + const dist = this.countDistribution(L); let tail = 0; for (let i = k; i <= n; i++) { - tail += this.probExactlyK(L, i); + tail += dist[i]; } if (tail < 0) return 0; @@ -432,26 +465,10 @@ export class DiceQuery { return probabilityArray[k]; } - // For multiple labels, we need to compute success probability for each single attack - const successProbabilities = this.singles.map((single) => { - const singleQuery = new DiceQuery([single]); - return singleQuery.probabilityOf(labels); - }); - - // Use binomial distribution with combined success probability - const binomialProbs = new Array(k + 1).fill(0); - binomialProbs[0] = 1; - - for (const successProb of successProbabilities) { - for (let outcomeCount = k; outcomeCount >= 1; outcomeCount--) { - binomialProbs[outcomeCount] = - binomialProbs[outcomeCount] * (1 - successProb) + - binomialProbs[outcomeCount - 1] * successProb; - } - binomialProbs[0] *= 1 - successProb; - } - - return binomialProbs[k]; + // For multiple labels, derive P(exactly k) from the single shared count + // distribution. (k > n is impossible, so index out of range reads as 0.) + const dist = this.countDistribution(labels); + return k >= 0 && k < dist.length ? dist[k] : 0; } /** @@ -483,10 +500,13 @@ export class DiceQuery { return cumulativeSum; } - // For multiple labels, compute probabilities for 0 to k + // For multiple labels, sum the lower tail of the single shared count + // distribution rather than recomputing it per outcomeCount. + const dist = this.countDistribution(labels); + const upper = Math.min(k, dist.length - 1); let cumulativeSum = 0; - for (let outcomeCount = 0; outcomeCount <= k; outcomeCount++) { - cumulativeSum += this.probExactlyK(labels, outcomeCount); + for (let outcomeCount = 0; outcomeCount <= upper; outcomeCount++) { + cumulativeSum += dist[outcomeCount]; } return cumulativeSum; } diff --git a/tests/fixtures/refactor-characterization.json b/tests/fixtures/refactor-characterization.json new file mode 100644 index 0000000..80badee --- /dev/null +++ b/tests/fixtures/refactor-characterization.json @@ -0,0 +1 @@ +{"kh3of4d6":{"3":{"p":0.0007716049382716042,"count":{}},"4":{"p":0.0030864197530864183,"count":{}},"5":{"p":0.007716049382716047,"count":{}},"6":{"p":0.0162037037037037,"count":{}},"7":{"p":0.029320987654320986,"count":{}},"8":{"p":0.04783950617283951,"count":{}},"9":{"p":0.07021604938271604,"count":{}},"10":{"p":0.0941358024691358,"count":{}},"11":{"p":0.11419753086419754,"count":{}},"12":{"p":0.12885802469135804,"count":{}},"13":{"p":0.13271604938271606,"count":{}},"14":{"p":0.12345679012345678,"count":{}},"15":{"p":0.10108024691358025,"count":{}},"16":{"p":0.07253086419753087,"count":{}},"17":{"p":0.041666666666666664,"count":{}},"18":{"p":0.016203703703703703,"count":{}}},"kl2of5d8":{"2":{"p":0.12072753906249997,"count":{}},"3":{"p":0.16860961914062494,"count":{}},"4":{"p":0.18023681640625003,"count":{}},"5":{"p":0.15869140625,"count":{}},"6":{"p":0.12957763671875003,"count":{}},"7":{"p":0.09292602539062503,"count":{}},"8":{"p":0.06396484375000001,"count":{}},"9":{"p":0.039062500000000014,"count":{}},"10":{"p":0.02383422851562501,"count":{}},"11":{"p":0.012359619140625003,"count":{}},"12":{"p":0.006439208984375003,"count":{}},"13":{"p":0.0024414062500000013,"count":{}},"14":{"p":0.0009460449218750007,"count":{}},"15":{"p":0.0001525878906250001,"count":{}},"16":{"p":0.00003051757812500002,"count":{}}},"kh2of6d6":{"2":{"p":0.000021433470507544556,"count":{}},"3":{"p":0.0001286008230452674,"count":{}},"4":{"p":0.0013503086419753081,"count":{}},"5":{"p":0.004115226337448558,"count":{}},"6":{"p":0.014253257887517145,"count":{}},"7":{"p":0.03125,"count":{}},"8":{"p":0.07203789437585734,"count":{}},"9":{"p":0.12757201646090535,"count":{}},"10":{"p":0.21585648148148148,"count":{}},"11":{"p":0.27019032921810693,"count":{}},"12":{"p":0.2632244513031551,"count":{}}},"kh1of6d10":{"1":{"p":0.000001000000000005723,"count":{}},"2":{"p":0.00006300000000036046,"count":{}},"3":{"p":0.0006650000000038152,"count":{}},"4":{"p":0.0033670000000193905,"count":{}},"5":{"p":0.011529000000063959,"count":{}},"6":{"p":0.03103100000018796,"count":{}},"7":{"p":0.07099300000045661,"count":{}},"8":{"p":0.14449500000095072,"count":{}},"9":{"p":0.2692970000012541,"count":{}},"10":{"p":0.4685589999970631,"count":{}}},"kh1of10d20":{"1":{"p":9.765624999999984e-14,"count":{}},"2":{"p":9.990234374999984e-11,"count":{}},"3":{"p":5.666503906249996e-9,"count":{}},"4":{"p":9.663349609374983e-8,"count":{}},"5":{"p":8.51274316406248e-7,"count":{}},"6":{"p":0.0000049512256835937364,"count":{}},"7":{"p":0.000021680573535156185,"count":{}},"8":{"p":0.0000772721264648435,"count":{}},"9":{"p":0.0002356486891601555,"count":{}},"10":{"p":0.0006360562108398416,"count":{}},"11":{"p":0.001556389121191401,"count":{}},"12":{"p":0.0035136659788085863,"count":{}},"13":{"p":0.007416125744628898,"count":{}},"14":{"p":0.014784781555371081,"count":{}},"15":{"p":0.02806598980947265,"count":{}},"16":{"p":0.05106066769052736,"count":{}},"17":{"p":0.08950022194072271,"count":{}},"18":{"p":0.15180403575927748,"count":{}},"19":{"p":0.2500584991383792,"count":{}},"20":{"p":0.40126306076162066,"count":{}}},"kl1of5d8":{"1":{"p":0.487091064453125,"count":{}},"2":{"p":0.275604248046875,"count":{}},"3":{"p":0.141937255859375,"count":{}},"4":{"p":0.064117431640625,"count":{}},"5":{"p":0.023834228515625,"count":{}},"6":{"p":0.006439208984375,"count":{}},"7":{"p":0.000946044921875,"count":{}},"8":{"p":0.000030517578125,"count":{}}},"maxOf8_d8":{"1":{"p":5.960464477539063e-8,"count":{}},"2":{"p":0.00001519918441772461,"count":{}},"3":{"p":0.0003758072853088379,"count":{}},"4":{"p":0.003515183925628662,"count":{}},"5":{"p":0.019376814365386963,"count":{}},"6":{"p":0.07682985067367554,"count":{}},"7":{"p":0.24349600076675415,"count":{}},"8":{"p":0.6563910841941833,"count":{}}},"maxOf3_d6":{"1":{"p":0.0046296296296296285,"count":{}},"2":{"p":0.0324074074074074,"count":{}},"3":{"p":0.08796296296296292,"count":{}},"4":{"p":0.1712962962962961,"count":{}},"5":{"p":0.2824074074074072,"count":{}},"6":{"p":0.42129629629629667,"count":{}}},"branch_0.3":{"2":{"p":0.0875,"count":{"hit":0.0875},"attr":{"hit":0.175}},"3":{"p":0.0875,"count":{"hit":0.0875},"attr":{"hit":0.26249999999999996}},"4":{"p":0.0875,"count":{"hit":0.0875},"attr":{"hit":0.35}},"5":{"p":0.09583333333333333,"count":{"hit":0.09583333333333333},"attr":{"hit":0.4791666666666667}},"6":{"p":0.10416666666666666,"count":{"hit":0.10416666666666666},"attr":{"hit":0.6249999999999999}},"7":{"p":0.11249999999999999,"count":{"hit":0.11249999999999999},"attr":{"hit":0.7875}},"8":{"p":0.12083333333333332,"count":{"hit":0.12083333333333332},"attr":{"hit":0.9666666666666666}},"9":{"p":0.12916666666666665,"count":{"hit":0.12916666666666665},"attr":{"hit":1.1625}},"10":{"p":0.049999999999999996,"count":{"hit":0.049999999999999996},"attr":{"hit":0.5}},"11":{"p":0.041666666666666664,"count":{"hit":0.041666666666666664},"attr":{"hit":0.45833333333333326}},"12":{"p":0.03333333333333333,"count":{"hit":0.03333333333333333},"attr":{"hit":0.39999999999999997}},"13":{"p":0.024999999999999998,"count":{"hit":0.024999999999999998},"attr":{"hit":0.32499999999999996}},"14":{"p":0.016666666666666666,"count":{"hit":0.016666666666666666},"attr":{"hit":0.23333333333333334}},"15":{"p":0.008333333333333333,"count":{"hit":0.008333333333333333},"attr":{"hit":0.125}}},"branch_withProbability_0.25":{"0":{"p":0.75,"count":{"miss":0.75},"attr":{}},"5":{"p":0.006944444444444444,"count":{"hit":0.006944444444444444},"attr":{"hit":0.034722222222222224}},"6":{"p":0.013888888888888888,"count":{"hit":0.013888888888888888},"attr":{"hit":0.08333333333333333}},"7":{"p":0.020833333333333332,"count":{"hit":0.020833333333333332},"attr":{"hit":0.14583333333333334}},"8":{"p":0.027777777777777776,"count":{"hit":0.027777777777777776},"attr":{"hit":0.2222222222222222}},"9":{"p":0.034722222222222224,"count":{"hit":0.034722222222222224},"attr":{"hit":0.3125}},"10":{"p":0.041666666666666664,"count":{"hit":0.041666666666666664},"attr":{"hit":0.4166666666666667}},"11":{"p":0.034722222222222224,"count":{"hit":0.034722222222222224},"attr":{"hit":0.3819444444444444}},"12":{"p":0.027777777777777776,"count":{"hit":0.027777777777777776},"attr":{"hit":0.3333333333333333}},"13":{"p":0.020833333333333332,"count":{"hit":0.020833333333333332},"attr":{"hit":0.2708333333333333}},"14":{"p":0.013888888888888888,"count":{"hit":0.013888888888888888},"attr":{"hit":0.19444444444444445}},"15":{"p":0.006944444444444444,"count":{"hit":0.006944444444444444},"attr":{"hit":0.10416666666666667}}},"gate_0.5":{"2":{"p":0.0625,"count":{"hit":0.0625},"attr":{"hit":0.125}},"3":{"p":0.0625,"count":{"hit":0.0625},"attr":{"hit":0.1875}},"4":{"p":0.0625,"count":{"hit":0.0625},"attr":{"hit":0.25}},"5":{"p":0.0763888888888889,"count":{"hit":0.0763888888888889},"attr":{"hit":0.3819444444444444}},"6":{"p":0.09027777777777778,"count":{"hit":0.09027777777777778},"attr":{"hit":0.5416666666666666}},"7":{"p":0.10416666666666666,"count":{"hit":0.10416666666666666},"attr":{"hit":0.7291666666666667}},"8":{"p":0.11805555555555555,"count":{"hit":0.11805555555555555},"attr":{"hit":0.9444444444444444}},"9":{"p":0.13194444444444445,"count":{"hit":0.13194444444444445},"attr":{"hit":1.1875}},"10":{"p":0.08333333333333333,"count":{"hit":0.08333333333333333},"attr":{"hit":0.8333333333333334}},"11":{"p":0.06944444444444445,"count":{"hit":0.06944444444444445},"attr":{"hit":0.7638888888888888}},"12":{"p":0.05555555555555555,"count":{"hit":0.05555555555555555},"attr":{"hit":0.6666666666666666}},"13":{"p":0.041666666666666664,"count":{"hit":0.041666666666666664},"attr":{"hit":0.5416666666666666}},"14":{"p":0.027777777777777776,"count":{"hit":0.027777777777777776},"attr":{"hit":0.3888888888888889}},"15":{"p":0.013888888888888888,"count":{"hit":0.013888888888888888},"attr":{"hit":0.20833333333333334}}},"reroll_d6_1":{"1":{"p":0.027777777777777776,"count":{"hit":0.027777777777777776},"attr":{"hit":0.027777777777777776}},"2":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.3888888888888889}},"3":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.5833333333333334}},"4":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.7777777777777778}},"5":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.9722222222222222}},"6":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":1.1666666666666667}}},"reroll_d8_2":{"1":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.140625}},"2":{"p":0.015625,"count":{"hit":0.015625},"attr":{"hit":0.03125}},"3":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.421875}},"4":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.5625}},"5":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.703125}},"6":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.84375}},"7":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":0.984375}},"8":{"p":0.140625,"count":{"hit":0.140625},"attr":{"hit":1.125}}},"hd6":{"1":{"p":0.027777777777777776,"count":{"hit":0.027777777777777776},"attr":{"hit":0.027777777777777776}},"2":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.3888888888888889}},"3":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.5833333333333334}},"4":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.7777777777777778}},"5":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":0.9722222222222222}},"6":{"p":0.19444444444444445,"count":{"hit":0.19444444444444445},"attr":{"hit":1.1666666666666667}}},"d6_plus_d4":{"2":{"p":0.041666666666666664,"count":{"hit":0.041666666666666664},"attr":{"hit":0.08333333333333333}},"3":{"p":0.08333333333333333,"count":{"hit":0.08333333333333333},"attr":{"hit":0.25}},"4":{"p":0.125,"count":{"hit":0.125},"attr":{"hit":0.5}},"5":{"p":0.16666666666666666,"count":{"hit":0.16666666666666666},"attr":{"hit":0.8333333333333334}},"6":{"p":0.16666666666666666,"count":{"hit":0.16666666666666666},"attr":{"hit":1}},"7":{"p":0.16666666666666666,"count":{"hit":0.16666666666666666},"attr":{"hit":1.1666666666666667}},"8":{"p":0.125,"count":{"hit":0.125},"attr":{"hit":1}},"9":{"p":0.08333333333333333,"count":{"hit":0.08333333333333333},"attr":{"hit":0.75}},"10":{"p":0.041666666666666664,"count":{"hit":0.041666666666666664},"attr":{"hit":0.4166666666666667}}},"d8_max_d6":{"1":{"p":0.020833333333333332,"count":{"hit":0.020833333333333332},"attr":{"hit":0.020833333333333332}},"2":{"p":0.0625,"count":{"hit":0.0625},"attr":{"hit":0.125}},"3":{"p":0.10416666666666667,"count":{"hit":0.10416666666666667},"attr":{"hit":0.3125}},"4":{"p":0.14583333333333334,"count":{"hit":0.14583333333333334},"attr":{"hit":0.5833333333333334}},"5":{"p":0.1875,"count":{"hit":0.1875},"attr":{"hit":0.9375}},"6":{"p":0.22916666666666666,"count":{"hit":0.22916666666666666},"attr":{"hit":1.375}},"7":{"p":0.125,"count":{"hit":0.125},"attr":{"hit":0.875}},"8":{"p":0.125,"count":{"hit":0.125},"attr":{"hit":1}}},"compact_result":{"5":{"p":0.5,"count":{"hit":0.5},"attr":{"hit":2.5}},"7":{"p":0.5,"count":{"hit":0.5}}},"countquery_exactly":[0.08662499999999995,0.33762499999999995,0.4148750000000001,0.16087500000000002],"countquery_atLeast":[1,0.913375,0.5757500000000001,0.16087500000000002],"countquery_atMost":[0.08662499999999995,0.4242499999999999,0.839125,1],"countquery_exactly_single":[0.08662499999999995,0.33762499999999995,0.4148750000000001,0.16087500000000002]} diff --git a/tests/parser-resource-guards.test.ts b/tests/parser-resource-guards.test.ts new file mode 100644 index 0000000..0a46a2b --- /dev/null +++ b/tests/parser-resource-guards.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { DiceParseError, parse } from "../src/index"; + +/** + * Resource-exhaustion guards (item 7). + * + * Adversarial expressions — an enormous die, an enormous dice count, or a keep + * over a combinatorially huge enumerated pool — must be rejected rather than + * exhausting memory/CPU. The caps are generous enough that every legitimate + * expression still parses. + */ +describe("parser resource-exhaustion guards", () => { + describe("die size cap", () => { + it("rejects a die larger than the cap", () => { + expect(() => parse("d2000000")).toThrow(/exceeds the maximum/); + expect(() => parse("d2000000")).toThrow(DiceParseError); + }); + + it("still allows large-but-legal dice (d100000)", () => { + const r = parse("d100000"); + expect(r.support().length).toBe(100000); + expect(r.max()).toBe(100000); + }); + + it("allows a normal die", () => { + expect(parse("d20").max()).toBe(20); + }); + }); + + describe("dice count cap", () => { + it("rejects a dice count larger than the cap", () => { + expect(() => parse("10001d2")).toThrow(/Dice count .* exceeds the maximum/); + expect(() => parse("20000d6")).toThrow(DiceParseError); + }); + + it("still allows a modest dice count", () => { + const r = parse("50d6"); + expect(r.min()).toBe(50); + expect(r.max()).toBe(300); + }); + }); + + describe("keep enumeration cap", () => { + it("rejects a keep over a combinatorially huge pool", () => { + // 10^10 enumerated outcomes — far over the cap. + expect(() => parse("10kh1d10")).toThrow(/Keep enumeration .* exceeds the maximum/); + expect(() => parse("10kh1d10")).toThrow(DiceParseError); + }); + + it("still allows a normal keep pool (4kh3d6 = 6^4 outcomes)", () => { + const r = parse("4kh3d6"); + // keep highest 3 of 4d6 -> support 3..18 + expect(r.min()).toBe(3); + expect(r.max()).toBe(18); + expect(r.mass()).toBeCloseTo(1, 9); + }); + }); + + describe("binary-operation work budget", () => { + it("rejects a binary op between two large dice (product of face counts)", () => { + // Each operand is individually legal (well under the die-size cap), but the + // dice×dice operation is O(faces1 × faces2) and would otherwise hang. + expect(() => parse("d20000 + d20000")).toThrow(/face pairs.*exceeds the maximum/); + expect(() => parse("d20000 + d20000")).toThrow(DiceParseError); + // Also covers other dice×dice operators (multiply / comparison). + expect(() => parse("d20000 ** d20000")).toThrow(DiceParseError); + expect(() => parse("d50000 > d50000")).toThrow(DiceParseError); + }); + + it("still allows a normal binary op between modest dice", () => { + const r = parse("d1000 + d1000"); // 10^6 face pairs, well under the budget + expect(r.min()).toBe(2); + expect(r.max()).toBe(2000); + expect(r.mass()).toBeCloseTo(1, 9); + }); + }); +}); diff --git a/tests/refactor-characterization.test.ts b/tests/refactor-characterization.test.ts new file mode 100644 index 0000000..cc369b7 --- /dev/null +++ b/tests/refactor-characterization.test.ts @@ -0,0 +1,271 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DiceQuery, EPS, parse, PMF } from "../src/index"; +import { d6, d8, d10, d20 } from "../src/builder/index"; + +/** + * Characterization / lock-down suite for the optimization + correctness pass. + * + * Each scenario's output was captured from the pre-change branch and frozen in + * tests/fixtures/refactor-characterization.json. The behavior-preserving + * refactors (integer DP keys, O(N) max-of CDF, single-pass branch mixture, + * Set-based reroll, hoisted binaryOp keys, re-derived count queries) must leave + * these outputs unchanged. compact()'s captured RESULT must also be unchanged; + * its separate test below additionally asserts the source is no longer mutated. + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const baseline = JSON.parse( + readFileSync(join(__dirname, "fixtures/refactor-characterization.json"), "utf8") +) as Record; + +type BinObj = { p: number; count: Record; attr?: Record }; + +function pmfToObj(p: PMF): Record { + const out: Record = {}; + for (const [k, bin] of [...p.map.entries()].sort((a, b) => a[0] - b[0])) { + out[k] = { + p: bin.p, + count: { ...bin.count } as Record, + attr: bin.attr ? ({ ...bin.attr } as Record) : undefined, + }; + } + return out; +} + +// Deep float comparison with tight tolerance. The refactors are intended to be +// bit-identical; the tolerance only absorbs sub-ULP re-association. +function expectClose(actual: number, expected: number, label: string) { + const tol = 1e-12 + 1e-9 * Math.abs(expected); + expect( + Math.abs(actual - expected) <= tol, + `${label}: got ${actual}, expected ${expected}` + ).toBe(true); +} + +function expectPMFMatches(actual: Record, key: string) { + const expected = baseline[key] as Record; + expect(Object.keys(actual).sort(), `${key}: support`).toEqual( + Object.keys(expected).sort() + ); + for (const dmg of Object.keys(expected)) { + const a = actual[dmg]; + const e = expected[dmg]; + expect(a, `${key}: bin ${dmg} present`).toBeDefined(); + expectClose(a.p, e.p, `${key} bin ${dmg} p`); + const ckeys = new Set([ + ...Object.keys(a.count ?? {}), + ...Object.keys(e.count ?? {}), + ]); + for (const c of ckeys) { + expectClose(a.count?.[c] ?? 0, e.count?.[c] ?? 0, `${key} bin ${dmg} count.${c}`); + } + const akeys = new Set([ + ...Object.keys(a.attr ?? {}), + ...Object.keys(e.attr ?? {}), + ]); + for (const c of akeys) { + expectClose(a.attr?.[c] ?? 0, e.attr?.[c] ?? 0, `${key} bin ${dmg} attr.${c}`); + } + } +} + +describe("refactor characterization: keepSumPMF DP (item 2)", () => { + it("keepHighest(4,3) of d6", () => + expectPMFMatches(pmfToObj(d6.keepHighest(4, 3).toPMF()), "kh3of4d6")); + it("keepLowest(5,2) of d8", () => + expectPMFMatches(pmfToObj(d8.keepLowest(5, 2).toPMF()), "kl2of5d8")); + it("keepHighest(6,2) of d6", () => + expectPMFMatches(pmfToObj(d6.keepHighest(6, 2).toPMF()), "kh2of6d6")); +}); + +describe("refactor characterization: computeMaxOfPMF (item 3)", () => { + it("keepHighest(6,1) of d10 (small enumerate)", () => + expectPMFMatches(pmfToObj(d10.keepHighest(6, 1).toPMF()), "kh1of6d10")); + it("keepHighest(10,1) of d20 (large-count CDF path)", () => + expectPMFMatches(pmfToObj(d20.keepHighest(10, 1).toPMF()), "kh1of10d20")); + it("keepLowest(5,1) of d8 (min via negated max)", () => + expectPMFMatches(pmfToObj(d8.keepLowest(5, 1).toPMF()), "kl1of5d8")); + it("maxOf(8) of d8 (large-count CDF path)", () => + expectPMFMatches(pmfToObj(d8.maxOf(8).toPMF()), "maxOf8_d8")); + it("maxOf(3) of d6 (small enumerate)", () => + expectPMFMatches(pmfToObj(d6.maxOf(3).toPMF()), "maxOf3_d6")); +}); + +describe("refactor characterization: PMF.branch (item 4)", () => { + it("branch(2d6+3, 1d8+1, 0.3)", () => { + const out = PMF.branch(parse("2d6+3"), parse("1d8+1"), 0.3); + expectPMFMatches(pmfToObj(out), "branch_0.3"); + }); + it("withProbability(2d6+3, 0.25)", () => + expectPMFMatches( + pmfToObj(PMF.withProbability(parse("2d6+3"), 0.25)), + "branch_withProbability_0.25" + )); + it("gate(0.5)", () => + expectPMFMatches(pmfToObj(parse("2d6+3").gate(0.5, parse("1d8+1"))), "gate_0.5")); + it("preserves identifier (feeds convolve cache key)", () => { + const a = parse("2d6+3"); + const b = parse("1d8+1"); + const out = PMF.branch(a, b, 0.3); + expect(out.identifier).toBe( + `branch(${b.identifier}*${(0.7).toFixed(6)} + ${a.identifier}*${(0.3).toFixed(6)})` + ); + }); +}); + +describe("refactor characterization: reroll (item 5)", () => { + it("d6 reroll 1", () => expectPMFMatches(pmfToObj(parse("d6 reroll 1")), "reroll_d6_1")); + it("d8 reroll 2", () => expectPMFMatches(pmfToObj(parse("d8 reroll 2")), "reroll_d8_2")); + it("hd6 (halfling luck)", () => expectPMFMatches(pmfToObj(parse("hd6")), "hd6")); +}); + +describe("refactor characterization: binaryOp non-scalar (item 6)", () => { + it("d6 + d4", () => expectPMFMatches(pmfToObj(parse("d6 + d4")), "d6_plus_d4")); + it("d8 > d6 (max)", () => expectPMFMatches(pmfToObj(parse("d8 > d6")), "d8_max_d6")); +}); + +describe("refactor characterization: count queries (item 9)", () => { + const buildQuery = () => + new DiceQuery([ + parse("(d20 + 5 ac 15) * (2d6 + 3)"), + parse("(d20 + 3 ac 15) * (1d8 + 2)"), + parse("(d20 + 7 ac 15) * (1d10 + 4)"), + ]); + const arr = ["hit", "crit"] as any; + + it("probExactlyK array path matches baseline", () => { + const q = buildQuery(); + for (let k = 0; k <= 3; k++) { + expectClose(q.probExactlyK(arr, k), baseline.countquery_exactly[k], `exactly ${k}`); + } + }); + it("probAtLeastK array path matches baseline", () => { + const q = buildQuery(); + for (let k = 0; k <= 3; k++) { + expectClose(q.probAtLeastK(arr, k), baseline.countquery_atLeast[k], `atLeast ${k}`); + } + }); + it("probAtMostK array path matches baseline", () => { + const q = buildQuery(); + for (let k = 0; k <= 3; k++) { + expectClose(q.probAtMostK(arr, k), baseline.countquery_atMost[k], `atMost ${k}`); + } + }); + it("single-label path unchanged", () => { + const q = buildQuery(); + for (let k = 0; k <= 3; k++) { + expectClose( + q.probExactlyK("hit" as any, k), + baseline.countquery_exactly_single[k], + `exactly-single ${k}` + ); + } + }); + + it("single-attack (n=1) array path collapses to the per-event marginal", () => { + // n=1 is the most common real call and collapses the binomial DP. For a + // single attack, P(exactly 1) must equal the per-event marginal P(>=1), and + // the array path must agree with the single-label string path. + const q = new DiceQuery([parse("(d20 + 5 ac 15) * (2d6 + 3)")]); + const arr = ["hit"] as any; + const marginal = q.probAtLeastOne("hit" as any); + expect(marginal).toBeGreaterThan(0); // sanity: the label exists + expect(q.probExactlyK(arr, 1)).toBeCloseTo(marginal, 10); + expect(q.probExactlyK(arr, 0)).toBeCloseTo(1 - marginal, 10); + expect(q.probExactlyK(arr, 2)).toBe(0); // k > n + expect(q.probExactlyK(arr, 1)).toBeCloseTo(q.probExactlyK("hit" as any, 1), 12); + expect(q.probAtLeastK(arr, 1)).toBeCloseTo(marginal, 10); + expect(q.probAtMostK(arr, 0)).toBeCloseTo(1 - marginal, 10); + }); + + it("array count distribution is internally consistent", () => { + const q = buildQuery(); + let sum = 0; + for (let k = 0; k <= 3; k++) sum += q.probExactlyK(arr, k); + expect(sum).toBeCloseTo(1, 10); + // atLeastK and atMostK are complementary slices of the same distribution + for (let k = 0; k <= 3; k++) { + const atMostKMinus1 = k === 0 ? 0 : q.probAtMostK(arr, k - 1); + expect(q.probAtLeastK(arr, k)).toBeCloseTo(1 - atMostKMinus1, 10); + } + }); +}); + +describe("combinedWithAttribution slow path (builder PMFs without attribution)", () => { + it("runs the withAttribution()+convolve fallback and stays consistent with combined", () => { + // Builder-generated PMFs carry only `count` (no `attr`), so this takes the + // slow path rather than the fast hasAttribution() identity reuse. + const singles = [d6.plus(3).toPMF(), d8.toPMF()]; + expect(singles.every((p) => !p.hasAttribution())).toBe(true); + + const q = new DiceQuery(singles); + const attributed = q.combinedWithAttribution(); + + expect(attributed.mass()).toBeCloseTo(1, 9); + expect(attributed.support()).toEqual(q.combined.support()); + // Per-bin probabilities match the plain combined distribution. + for (const v of q.combined.support()) { + expectClose( + attributed.map.get(v)!.p, + q.combined.map.get(v)!.p, + `combinedWithAttribution bin ${v} p` + ); + } + }); +}); + +describe("compact() (item 1)", () => { + it("drops sub-eps count/attr entries in the RESULT (unchanged)", () => { + const m = new Map(); + m.set(5, { p: 0.5, count: { hit: 0.5, tiny: 1e-20 }, attr: { hit: 2.5, tiny: 1e-20 } }); + m.set(7, { p: 0.5, count: { hit: 0.5 } }); + m.set(9, { p: 1e-20, count: { hit: 1e-20 } }); + const pmf = new PMF(m, EPS); + expectPMFMatches(pmfToObj(pmf.compact()), "compact_result"); + }); + + it("does not mutate the receiver's own bins", () => { + const m = new Map(); + m.set(5, { p: 0.5, count: { hit: 0.5, tiny: 1e-20 }, attr: { hit: 2.5, tiny: 1e-20 } }); + m.set(7, { p: 0.5, count: { hit: 0.5 } }); + const pmf = new PMF(m, EPS); + pmf.compact(); + expect(pmf.map.get(5)!.count.tiny).toBe(1e-20); + expect(pmf.map.get(5)!.attr!.tiny).toBe(1e-20); + }); + + it("does not corrupt another PMF that shares the same bin objects by reference", () => { + // Bins are shared by reference across PMFs (branch()/addScaled()/scaleMass() + // fast paths can carry another PMF's bin objects). Compacting one PMF must + // not delete sub-eps entries out of a bin a different PMF still holds. + const sharedBin = { + p: 0.5, + count: { hit: 0.5, tiny: 1e-20 }, + attr: { hit: 2.5, tiny: 1e-20 }, + }; + const a = new PMF( + new Map([ + [5, sharedBin], + [7, { p: 0.5, count: { hit: 0.5 } }], + ]), + EPS + ); + const b = new PMF(new Map([[5, sharedBin]]), EPS); + + b.compact(); + + expect(a.map.get(5)!.count.tiny).toBe(1e-20); + expect(a.map.get(5)!.attr!.tiny).toBe(1e-20); + }); + + it("branch(p=0) fast path returns a PMF whose compact() does not corrupt the failure branch", () => { + const failure = parse("2d6+1"); + const before = JSON.stringify(failure.toJSON()); + const branched = PMF.branch(parse("1d8"), failure, 0); + branched.compact(1e-9); // aggressive eps to force count pruning + expect(JSON.stringify(failure.toJSON())).toBe(before); + }); +});