Skip to content

fix: invert the one_of quantile by bisection, not Nelder-Mead (#356) - #376

Closed
seabbs-bot wants to merge 2 commits into
mainfrom
fix/356-quantile-root-find
Closed

fix: invert the one_of quantile by bisection, not Nelder-Mead (#356)#376
seabbs-bot wants to merge 2 commits into
mainfrom
fix/356-quantile-root-find

Conversation

@seabbs-bot

Copy link
Copy Markdown
Collaborator

Fixes the Windows Julia 1.x hang by removing the failure mode rather than bounding it. Also deletes an extension and two weak dependencies.

Stacked on #359 — the diff shown against main includes that PR's compat fix, because main cannot instantiate without it. Merge #359 first; this is one commit on top.

Diagnosis, narrowed

The hanging cell's log runs to 17:34:06 and then goes silent to the 60-minute timeout. The last two precompiles are the two Optimization extensions, both triggered by the single using Optimization, OptimizationOptimJL in test/composers/quantile_optimization.jl. Testitems run in file order, so the trigger is that file's first item, whose very next act is the first Optimization.solve.

Resolved versions are byte-identical between the hanging Windows cell and the passing Windows pre cell — Optim 2.2.1, Optimization 5.7.0, OptimizationOptimJL 0.4.18, SciMLBase 3.44.0. So this is Julia 1.12.6 on Windows against that solver stack, not a resolution difference.

The hang could not be reproduced locally on the same Julia and the same stack. So this is a well-evidenced hypothesis, not a proven cure, and Windows CI on this branch is the real test.

The fix

A CDF is monotone, so inverting it with a derivative-free simplex method was the wrong tool. Bisection has a bounded iteration count by construction, which removes the failure mode instead of putting a wall-clock bound near it — the bound #358 added could never have worked, since Optim only checks time_limit between iterations and cannot interrupt a single cdf evaluation.

New src/composers/one_of_quantile.jl: bracket grown from minimum/maximum and doubled outward when an end is unbounded (capped at 200 steps), bisection capped at 100 halvings, exiting when the midpoint lands on adjacent floats. Boundary shortcuts for p = 0/p = 1 and for p outside the reachable cdf of a defective race.

It never calls mean(d), so #344's non-finite starting point cannot recur, and a Compete quantile no longer pays for a panelled-quadrature mean.

Accuracy and speed

Five node shapes at ten probabilities each, comparing |cdf(quantile(d, p)) - p|:

bisection Nelder-Mead
worst error 2.22e-16 3.69e-5
typical error 0 to 1.1e-16 1e-9 to 3e-5
steady-state call 6-33 µs 55-320 µs

Round-trip tolerances tightened from atol = 1e-3 to atol = 1e-10, and checked at rtol = 1e-8 against an independently written bisection reference.

Breaking, and an improvement

ext/ComposedDistributionsOptimizationExt.jl is deleted, along with the Optimization/OptimizationOptimJL weak dependencies, the extension entry and its compat. No solver is involved any more, so the methods move into src.

quantile on Resolve/Compete now works with nothing but this package loaded. A subprocess testitem pins that. Anyone who was loading Optimization solely to get these quantiles no longer needs to.

Legibility, so a future hang names itself

test/runtests.jl runs one file at a time, printing a timestamped banner naming the file and its testitems before, and the wall clock after, with explicit flushing — so a wedged process leaves its last banner in the log. Per-testitem granularity is not reachable without reimplementing TestItemRunner's run loop, since its filter hook fires only at discovery; file-level plus the item list is the honest ceiling. Cost is about 0.4 s per file.

It has already earned its keep: a local run showed one file taking 955 s against 11.4 s clean, which turned out to be contention from another process on the same box. Previously that would have been invisible.

The CD-side fix may not be sufficient alone

quantile_by_optimization still lives in ConvolvedDistributions and still backs Convolved/Difference/Product/Ratio. This package still reaches it two ways — a multi-step Sequential delegates to its Convolved total, and Compete's panel-break ladder calls quantile on a composite cause — so two testitems in test/composers/quantile.jl still load the solver stack. Tracked as an upstream change on our own repo. If Windows still hangs, the new banners will name the file, and the candidates are down to those two.

Gates

task test-fast pass across all 22 files, task test-quality 239 pass, task docs-fast builds with all new @refs resolving. Quantile paths exercised directly for Resolve and Compete including Cauchy (no finite mean), Pareto (unbounded mean — #344's cases), bounded support, a defective race, and monotonicity across p.

Closes #356. Refs #344.

This was opened by a bot. Please ping @seabbs for any questions.

The Windows Julia 1.x Test cell hung to the 60-minute job timeout. The job
log's last line is the ComposedDistributionsOptimizationExt precompile, then
55 minutes of silence. That precompile is triggered by the first quantile
testitem's `using Optimization, OptimizationOptimJL`, and the first thing it
then does is a Nelder-Mead solve. Windows on the Julia pre release runs the
same testitems, against the same Optim 2.2.1 / Optimization 5.7.0 /
SciMLBase 3.44.0 resolution, in under 8 minutes.

The #358 maxtime bound could not have helped. It reaches Optim's
`time_limit`, which is checked between iterations, so it cannot interrupt a
single evaluation.

A cdf is monotone, so its inverse is a root find, not an optimisation.
Resolve and Compete now bisect their own cdf over a bracket grown from the
support by doubling, which terminates in a step count fixed by construction.
The bracket needs no mean, so the non-finite starting point of #344 is gone
too. Measured against the old solve over five node shapes at ten
probabilities each, the worst |cdf(quantile(d, p)) - p| falls from 3.7e-5 to
2.2e-16, and a call is roughly nine times faster.

No solver is involved any more, so the methods move into src and the
Optimization / OptimizationOptimJL extension goes away. The quantile now
answers with nothing but the package loaded, and a subprocess testitem pins
that.

A Sequential chain's quantile still delegates to ConvolvedDistributions'
Convolved, whose own quantile is the same Nelder-Mead solve, so the two
testitems covering that arm still load the solver stack. The same bracketed
root find belongs upstream.

Also make a future hang legible. TestItemRunner prints nothing until its
summary, so runtests.jl now runs one test file at a time, announcing the
file and its items before starting and the wall clock after. A hang names
the file it is in instead of dying silently at the job timeout.
@github-actions

Copy link
Copy Markdown
Contributor

Try this Pull Request!

Option 1: Julia Package Manager

Open Julia and type:

import Pkg
Pkg.activate(temp=true)
Pkg.add(url="https://github.com/EpiAware/ComposedDistributions.jl", rev="fix/356-quantile-root-find")
using ComposedDistributions

Option 2: Local Checkout

If you have the repo locally:

git checkout fix/356-quantile-root-find
julia --project=. -e "using Pkg; Pkg.instantiate()"

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Flag Coverage Δ
ad-enzyme-forward 0.00% <0.00%> (ø)
ad-enzyme-reverse 13.44% <0.00%> (-0.19%) ⬇️
ad-forwarddiff 11.88% <0.00%> (-0.17%) ⬇️
ad-mooncake-forward 0.00% <0.00%> (ø)
ad-mooncake-reverse 12.08% <0.00%> (-0.17%) ⬇️
ad-reversediff 11.88% <0.00%> (-0.17%) ⬇️
unit 86.96% <100.00%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/composers/one_of_quantile.jl 100.00% <100.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Documentation preview is ready!

View the docs for this PR at: https://EpiAware.github.io/ComposedDistributions.jl/previews/PR376/

This preview will be updated automatically when you push new commits.

@seabbs seabbs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really not convinced we want our hand rolled method can't we just see if the issue has cleared and or use a different more approriate sovler or other?

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark comparison vs base

Minimum time per call. Buckets are PR time as a % of base, so lower is faster (🟢 faster, ⚪ within 5%, 🔴 slower). Counts of benchmarks per bucket:

Group 🟢 <50% 🟢 50–75% 🟢 75–95% ⚪ 95–105% 🔴 105–125% 🔴 125–150% 🔴 >150%
Evaluation · 2 3 10 2 · ·
ForwardDiff · · · 6 3 · ·
ReverseDiff (tape) · · · 6 3 · ·
Mooncake reverse · · · 3 3 3 ·
Enzyme reverse · · · 5 4 · ·
Evaluation — 17 benchmarks (by time change)
Benchmark base PR time memory
Composition / Choose / construct 30.0 ns 20.0 ns 🟢 0.67×
Composition / Compete / construct 30.0 ns 20.0 ns 🟢 0.67×
Composition / Choose / logpdf 60.0 ns 50.0 ns 🟢 0.83×
Composition / Compete / rand 500.0 ns 570.0 ns 🔴 1.14× ⚪ 1.0×
Composition / Resolve / rand 491.0 ns 551.0 ns 🔴 1.12× ⚪ 1.0×
Composition / Compete / logccdf 350.0 ns 320.0 ns 🟢 0.91×
Composition / Resolve / logpdf 140.0 ns 131.0 ns 🟢 0.94×
Composition / Parallel / rand 1.2 μs 1.25 μs ⚪ 1.04× ⚪ 1.0×
Composition / Sequential / rand 1.2 μs 1.25 μs ⚪ 1.04× ⚪ 1.0×
Composition / Sequential / construct 1.36 μs 1.41 μs ⚪ 1.04× ⚪ 1.0×
Composition / Resolve / construct 30.0 ns 29.0 ns ⚪ 0.97×
Composition / Parallel / construct 1.36 μs 1.4 μs ⚪ 1.03× ⚪ 1.0×
Composition / Nested / rand 4.44 μs 4.31 μs ⚪ 0.97× ⚪ 1.0×
Composition / Nested / compose 2.61 μs 2.66 μs ⚪ 1.02× ⚪ 1.0×
Composition / Nested / logpdf 1.64 μs 1.62 μs ⚪ 0.99× ⚪ 1.0×
Composition / Parallel / logpdf 110.0 ns 110.0 ns ⚪ 1.0× ⚪ 1.0×
Composition / Sequential / logpdf 100.0 ns 100.0 ns ⚪ 1.0× ⚪ 1.0×
AD gradients — 36 benchmarks (by time change)
Benchmark base PR time memory
AD gradients / Censored leaf marginal logpdf / Mooncake reverse 336.16 μs 464.48 μs 🔴 1.38× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / Mooncake reverse 304.5 μs 408.06 μs 🔴 1.34× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / Mooncake reverse 95.12 μs 119.62 μs 🔴 1.26× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / ForwardDiff 4.24 μs 5.12 μs 🔴 1.21× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / Mooncake reverse 156.68 μs 185.83 μs 🔴 1.19× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / Enzyme reverse 118.72 μs 132.93 μs 🔴 1.12× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / Enzyme reverse 99.19 μs 110.94 μs 🔴 1.12× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / Mooncake reverse 89.8 μs 100.3 μs 🔴 1.12× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / Enzyme reverse 18.49 μs 20.58 μs 🔴 1.11× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / ForwardDiff 10.35 μs 11.33 μs 🔴 1.1× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / Mooncake reverse 89.35 μs 97.78 μs 🔴 1.09× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / ReverseDiff (tape) 27.26 μs 29.43 μs 🔴 1.08× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / ForwardDiff 991.0 ns 1.06 μs 🔴 1.07× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / Enzyme reverse 3.18 μs 3.38 μs 🔴 1.06× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / ReverseDiff (tape) 13.6 μs 14.4 μs 🔴 1.06× ⚪ 1.0×
AD gradients / Truncated uncertain-leaf unflatten/update codec / ReverseDiff (tape) 12.42 μs 13.04 μs 🔴 1.05× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / ReverseDiff (tape) 53.08 μs 55.44 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / Mooncake reverse 20.97 μs 21.9 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / ReverseDiff (tape) 48.72 μs 50.82 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / ForwardDiff 711.0 ns 681.0 ns ⚪ 0.96× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / ReverseDiff (tape) 62.02 μs 64.45 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / ForwardDiff 7.65 μs 7.94 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / Enzyme reverse 20.91 μs 21.64 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / Mooncake reverse 52.55 μs 54.36 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Censored leaf marginal logpdf / ForwardDiff 10.36 μs 10.71 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / Enzyme reverse 20.59 μs 21.26 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / Enzyme reverse 1.93 μs 1.98 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / ReverseDiff (tape) 11.71 μs 12.01 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Resolve stick-breaking branch-prob logpdf / ForwardDiff 6.62 μs 6.79 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / Enzyme reverse 7.89 μs 8.04 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Pool non-centred reconstruction logpdf / ReverseDiff (tape) 25.43 μs 25.92 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Sequential Gamma+LogNormal logpdf / ReverseDiff (tape) 27.69 μs 28.22 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Resolve mixture marginal logpdf / ForwardDiff 6.75 μs 6.86 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Choose selected-branch logpdf / Mooncake reverse 16.55 μs 16.82 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Compete racing-hazard marginal logpdf / Enzyme reverse 9.85 μs 9.92 μs ⚪ 1.01× ⚪ 1.0×
AD gradients / Shared-tag unflatten/update codec / ForwardDiff 1.73 μs 1.72 μs ⚪ 0.99× ⚪ 1.0×

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Superseded by #381, which folds this and seven other PRs into one branch off main so the 0.2.0 contract reviews as a single diff. Its gates: test-fast 2379/2379, test-quality 255/255, full docs build clean.

This branch is preserved and the PR body is kept for its rationale — the detail here is the record of why, which #381 summarises rather than repeats. Closing so it does not compete for review.

This comment was posted by a bot. Please ping @seabbs for any questions.

@seabbs-bot seabbs-bot closed this Aug 12, 2026
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.

Windows Julia 1.x Test cell hangs to the 60-min timeout after the Optimization ext loads; main has no green Test run since #342

2 participants