Skip to content

Modernize, clean up, and update dependencies (v0.3.0)#2

Merged
yipe merged 10 commits into
mainfrom
claude/elegant-dewdney-810505
Jun 28, 2026
Merged

Modernize, clean up, and update dependencies (v0.3.0)#2
yipe merged 10 commits into
mainfrom
claude/elegant-dewdney-810505

Conversation

@yipe

@yipe yipe commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Cleanup, modernization, and safety pass over @yipe/dice, plus a full dependency bump. No change to the probability math / numeric output — all 1085 original tests still pass (1091 with new coverage).

Verification

typecheck ✓ · lint (ESLint 10) ✓ · 1091 tests ✓ · build ✓ · npm pack ✓ · example ✓ · yarn npm audit → no advisories.

Commit 1 — Modernize & clean up (v0.3.0)

Tier 1 — safe cleanup (no behavior change)

  • Removed stale package-lock.json (the project uses Yarn 4) and unused devDeps (ts-node, tsconfig-paths).
  • Removed console.error calls from the parser so the library no longer writes to a consumer's console (defensive behavior preserved).
  • Fixed typos / stale comments; corrected invalid 'miss' OutcomeType references in DiceQuery JSDoc.

Tier 2 — internal refactor (no behavior change)

  • Dropped impossible [number, number] iterator branches and the matching as any casts (PMF always iterates [number, Bin]).
  • Deduplicated Bin clone/scale logic in PMF via cloneBin/scaleBin helpers (normalize() keeps direct division for bit-identical results).
  • Removed dead commented-out code; tightened internal any (typed Dice.privateData, MaxOfNode, a typed view for the protected lastConfig).

Tier 3 — API changes (minor breaking → v0.3.0)

  • Added DiceParseError (exported); parse() throws it with .expression / .cause. Messages unchanged, so existing try/catch keeps working; now narrowable via instanceof.
  • PMF.toJSON() now returns a plain object (fixes JSON.stringify(pmf) double-encoding); added PMF.toJSONString() for the old string form.
  • Tightened DiceQuery.firstSuccessSplit() to OutcomeType | OutcomeType[].
  • Added DiceQuery.stdev() alias (matches PMF.stdev()).
  • Bumped 0.2.23 → 0.3.0; added CHANGELOG.md and a README error-handling example.

Commit 2 — Bump all dev dependencies to latest

Package Before After
@types/node ^24.13.2 ^26.0.1
@typescript-eslint/* ^8.61.0 ^8.62.0
@vitest/coverage-v8 + vitest ^4.1.8 ^4.1.9
eslint ^9.0.0 ^10.6.0

tsup (8.5.1), tsx (4.22.4), typescript (6.0.3) were already at latest.

Note

ESLint 10 and Vitest 4 require Node ≥20 for development only — the published package's engines (>=18.17) is unchanged since the built output has no such requirement for consumers. The CI matrix still lists Node 18; since yarn test runs Vitest 4 (Node ≥20), the Node 18 leg may warn/fail (pre-existing — Vitest was already v4). Left CI/engines untouched as that's a maintainer support-policy decision.

🤖 Generated with Claude Code

yipe and others added 10 commits June 26, 2026 23:21
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…d reroll

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 <noreply@anthropic.com>
…tions

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 <noreply@anthropic.com>
…s 2)

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@yipe yipe force-pushed the claude/elegant-dewdney-810505 branch from 38b7db2 to 3970cb8 Compare June 28, 2026 00:08
@yipe yipe merged commit 3970cb8 into main Jun 28, 2026
8 checks passed
@yipe yipe deleted the claude/elegant-dewdney-810505 branch June 28, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant