Skip to content

feat: add total_mass/is_defective accessors for sub-stochastic laws - #108

Merged
seabbs merged 3 commits into
mainfrom
feat/total-mass-defective-107
Aug 5, 2026
Merged

seabbs merged 3 commits into
mainfrom
feat/total-mass-defective-107

Conversation

@seabbs-bot

Copy link
Copy Markdown
Collaborator

Summary

  • Adds total_mass(d)/is_defective(d) for a modifyd law: a public,
    queryable surface for the sub-stochastic (defective) laws a negative
    additive-hazard effect (or a clamped numeric hazard modification) can
    produce.
  • total_mass is exact (1) for the proportional-hazards, non-negative
    additive-hazard and discrete per-bin paths (which never clamp away mass),
    and numerically searched (evaluated exactly at the base's own finite
    maximum when bounded, otherwise a doubling cap search mirroring the
    bracket the quantile methods already expand internally) for the clamped
    identity-link and numeric cumulative-hazard paths.
  • ccdf already reports the same residual deficit consistently
    (ccdf(d, x) -> 1 - total_mass(d) as x grows); tested directly.

Scope note

Deliberately scoped to Modified only — not peeled through Affine/
Weighted/Transformed. This is a foundation stone for the cross-org
defective-law epic (ComposedDistributions#252); the general
multi-wrapper accessor surface (e.g. total_mass of a weight-wrapped
Modified) is left to that epic's design rather than guessed here.

Closes #107.

Test plan

  • task test-fast — 2170/2170 pass (new cases: proper laws never
    defective across log/identity/discrete paths; negative-additive clamp
    matches the existing plateaued-cdf fixture; finite-support base evaluated
    exactly; ccdf/cdf consistency).
  • task test-quality — Aqua/formatting/doctests pass (109/109).
  • task test-ad — all AD backends pass (1386/1386).

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

A negative additive-hazard effect (or a clamped numeric hazard
modification) can leave a Modified law sub-stochastic, its cdf
converging to a value below one. total_mass/is_defective expose that
deficit as a public, queryable surface, so a caller can inspect it
before a quantile/rand query that would otherwise throw.

Closes #107.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
@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/ModifiedDistributions.jl", rev="feat/total-mass-defective-107")
using ModifiedDistributions

Option 2: Local Checkout

If you have the repo locally:

git checkout feat/total-mass-defective-107
julia --project=. -e "using Pkg; Pkg.instantiate()"

@codecov

codecov Bot commented Jul 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Modified.jl 84.61% 2 Missing ⚠️
Flag Coverage Δ
ad-enzyme-forward 27.14% <0.00%> (-0.73%) ⬇️
ad-enzyme-reverse 27.14% <0.00%> (-0.73%) ⬇️
ad-forwarddiff 16.16% <0.00%> (-0.44%) ⬇️
ad-mooncake-forward 16.16% <0.00%> (-0.44%) ⬇️
ad-mooncake-reverse 16.16% <0.00%> (-0.44%) ⬇️
ad-reversediff 16.16% <0.00%> (-0.44%) ⬇️
unit 97.42% <84.61%> (-0.34%) ⬇️

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

Files with missing lines Coverage Δ
src/Modified.jl 92.94% <84.61%> (-0.48%) ⬇️
🚀 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/ModifiedDistributions.jl/previews/PR108/

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

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Review

Verified the three exact-1.0 claims against the actual logccdf/hazard formulas (not just the tests):

  • Proportional hazards (log link): logccdf* = θ·logccdf(base, x), θ = exp(β) > 0 for any real β. Since logccdf(base, x) → -∞ for any proper base, θ·(-∞) = -∞ regardless of θ's value — provably never defective. ✓
  • Non-negative additive hazard (identity link): logccdf* = logccdf(base, x) - β·(x-m) for β ≥ 0. Both terms are non-increasing in x, so the sum still diverges to -∞ whenever the base does — provably never defective. ✓
  • Discrete per-bin: _apply_hazard_link pins hstar[n] = one(T) unconditionally for the final bin regardless of the effect vector, so hazard_to_pmf telescopes to a PMF that sums to exactly 1 by construction — provably never defective. ✓

Finding (structural, numeric-path): _defective_total_mass's doubling search relies on cdf(d, x), which — for the clamped identity path and the QuadGK numeric path — only detects hazard/clamp-level crossings up to _scan_upper(dist) (a 1-1e-8 deep quantile of the base). Beyond that, both _identity_cumhazard_clamped and the QuadGK _cumhazard characterise the entire remaining panel by a single midpoint hazard check.

I constructed an adversarial base with a genuine bathtub hazard (a hazard that dips below a clamp level and rises back above it beyond the deep-quantile window):

mix = MixtureModel([Exponential(0.05), Weibull(3.0, 50.0)], [0.9999, 0.0001])
d = modify(mix, -0.3; link = identity)
# scan_upper(mix) ≈ 104.8; hazard crosses back above the 0.3 clamp level
# somewhere in (110, 130) — beyond that window.

Comparing cdf(d, x) against a fine-grid (2M-point) direct numerical integration of the true clamped hazard:

x package cdf fine-grid ground truth diff
150 0.99987589 0.99999850 -1.23e-4
200 0.99987589 1.00000000 -1.24e-4

In this instance total_mass/is_defective still land on the correct proper/defective verdict (both converge to "proper"), but the intermediate cdf values feeding that convergence check are measurably wrong — the "exact" framing in the docstring/NEWS entry is doing more work than the implementation strictly guarantees for a non-monotone-hazard base with a late crossing. I don't have a case where this flips the actual defective/proper verdict, but I'd flag it as a documented assumption (single hazard crossing beyond _scan_upper) rather than leave it implicit, or widen the knot-scan to also anchor near the query point rather than only the base's own deep quantile.

ccdf/cdf consistency: confirmed exact for a genuinely defective case (modify(LogNormal(1.5,0.5), -0.4; link=identity), total_mass≈0.042346) — ccdf(d,x) + total_mass(d) - 1 == 0.0 exactly at x = 1e3, 1e6, 1e9, 1e12.

AD/hot-path isolation: total_mass/is_defective are referenced nowhere in src//ext/ outside their own definitions and the export line — confirmed by grep. Not on the differentiated logpdf path.

Minor doc gap: is_defective's docstring says "true when total_mass(d) < 1", but the implementation is total_mass(d) < 1 - 1e-9. A total_mass of 0.9999999999 (10 nines) would not be flagged defective under the real implementation despite reading < 1 in the docstring. Worth stating the tolerance explicitly.

Suites: reproduced task test-fast (2170/2170) and task test-quality (109/109) locally — match the PR's claims.

Overall: the core design and the three exact-mass proofs are sound. The numeric-path finding is a real gap worth a decision (doc caveat vs. widened scan vs. accepted limitation) rather than a blocker, and the tolerance-documentation fix is small.

@github-actions

github-actions Bot commented Jul 21, 2026 •

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 · · 7 20 12 · 1
ForwardDiff · · · 9 2 · ·
ReverseDiff (tape) · · 7 4 · · ·
Mooncake reverse · · 1 10 · · ·
Mooncake forward · · · 4 7 · ·
Enzyme reverse · · 1 10 · · ·
Enzyme forward · · 9 2 · · ·
Evaluation — 40 benchmarks (by time change)
Benchmark base PR time memory
Baseline / LogNormal / construction 20.0 ns 30.0 ns 🔴 1.5× —
Baseline / LogNormal / pdf 2.94 μs 3.34 μs 🔴 1.14× ⚪ 1.0×
Modified / IdentityLink / rand 540.98 μs 612.67 μs 🔴 1.13× ⚪ 1.0×
Modified / IdentityLink / ccdf 7.5 μs 8.49 μs 🔴 1.13× ⚪ 1.0×
Modified / IdentityLink / cdf 7.15 μs 8.09 μs 🔴 1.13× ⚪ 1.0×
Modified / IdentityLink / pdf 12.65 μs 14.29 μs 🔴 1.13× ⚪ 1.0×
Affine / LogNormal / pdf 3.76 μs 4.22 μs 🔴 1.12× ⚪ 1.0×
Modified / IdentityLink / logpdf 10.42 μs 11.62 μs 🔴 1.11× ⚪ 1.0×
Modified / LogLink / cdf 6.08 μs 6.66 μs 🔴 1.1× ⚪ 1.0×
Affine / LogNormal / rand 881.0 ns 811.0 ns 🟢 0.92× ⚪ 1.0×
Weighted / Product / logpdf 1.86 μs 1.72 μs 🟢 0.92× ⚪ 1.0×
Affine / LogNormal / ccdf 3.26 μs 3.5 μs 🔴 1.07× ⚪ 1.0×
Modified / LogLink / quantile 871.0 ns 811.0 ns 🟢 0.93× ⚪ 1.0×
Baseline / LogNormal / logpdf 1.62 μs 1.51 μs 🟢 0.93× ⚪ 1.0×
Transformed / cumulative / logpdf 1.62 μs 1.51 μs 🟢 0.93× ⚪ 1.0×
Affine / LogNormal / quantile 440.0 ns 410.0 ns 🟢 0.93× ⚪ 1.0×
Modified / LogLink / ccdf 5.74 μs 6.13 μs 🔴 1.07× ⚪ 1.0×
Affine / LogNormal / cdf 3.33 μs 3.54 μs 🔴 1.07× ⚪ 1.0×
Modified / IdentityLink / quantile 110.49 μs 117.63 μs 🔴 1.06× ⚪ 1.0×
Transformed / thin / logpdf 1.61 μs 1.51 μs 🟢 0.94× ⚪ 1.0×
Modified / LogLink / pdf 9.29 μs 8.83 μs ⚪ 0.95× ⚪ 1.0×
Baseline / LogNormal / quantile 410.0 ns 390.0 ns ⚪ 0.95× ⚪ 1.0×
Weighted / scalar / logpdf 1.68 μs 1.6 μs ⚪ 0.95× ⚪ 1.0×
Affine / LogNormal / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Modified / IdentityLink / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Modified / LogLink / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Transformed / cumulative / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Transformed / thin / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Weighted / scalar / construction 29.0 ns 30.0 ns ⚪ 1.03× —
Modified / LogLink / logpdf 8.0 μs 7.77 μs ⚪ 0.97× ⚪ 1.0×
Baseline / LogNormal / cdf 2.62 μs 2.69 μs ⚪ 1.03× ⚪ 1.0×
Transformed / cumulative / cdf 2.62 μs 2.69 μs ⚪ 1.03× ⚪ 1.0×
Transformed / thin / cdf 2.62 μs 2.69 μs ⚪ 1.03× ⚪ 1.0×
Baseline / LogNormal / ccdf 2.63 μs 2.7 μs ⚪ 1.03× ⚪ 1.0×
Modified / LogLink / rand 4.47 μs 4.56 μs ⚪ 1.02× ⚪ 1.0×
Baseline / LogNormal / rand 631.0 ns 620.0 ns ⚪ 0.98× ⚪ 1.0×
Transformed / thin / rand 610.0 ns 620.0 ns ⚪ 1.02× ⚪ 1.0×
Transformed / cumulative / rand 611.0 ns 620.0 ns ⚪ 1.01× ⚪ 1.0×
Affine / LogNormal / logpdf 2.59 μs 2.62 μs ⚪ 1.01× ⚪ 1.0×
Weighted / Product / construction 171.0 ns 170.0 ns ⚪ 0.99× ⚪ 1.0×
AD gradients — 66 benchmarks (by time change)
Benchmark base PR time memory
AD gradients / Weighted Affine LogNormal nested logpdf / Mooncake forward 6.91 μs 7.49 μs 🔴 1.08× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / Mooncake reverse 1.18 ms 1.09 ms 🟢 0.93× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / ForwardDiff 821.0 ns 881.0 ns 🔴 1.07× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / Mooncake forward 6.44 μs 6.91 μs 🔴 1.07× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / Enzyme reverse 314.89 μs 293.46 μs 🟢 0.93× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / Mooncake forward 7.92 μs 8.44 μs 🔴 1.07× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / ReverseDiff (tape) 2.23 ms 2.08 ms 🟢 0.94× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / ReverseDiff (tape) 16.12 μs 15.09 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / ReverseDiff (tape) 16.75 μs 15.71 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted Affine LogNormal nested logpdf / ReverseDiff (tape) 17.85 μs 16.75 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / ReverseDiff (tape) 16.78 μs 15.75 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / Mooncake forward 6.26 μs 6.64 μs 🔴 1.06× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / ReverseDiff (tape) 14.5 μs 13.63 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / Mooncake forward 7.17 μs 7.6 μs 🔴 1.06× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / ForwardDiff 511.0 ns 541.0 ns 🔴 1.06× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / Enzyme forward 7.8 μs 7.35 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted Affine LogNormal nested logpdf / Enzyme forward 8.04 μs 7.57 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / Enzyme forward 7.77 μs 7.33 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / Mooncake forward 5.62 μs 5.94 μs 🔴 1.06× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / Enzyme forward 7.86 μs 7.42 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / Enzyme forward 8.52 μs 8.04 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / Enzyme forward 8.38 μs 7.91 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / Enzyme forward 7.86 μs 7.43 μs 🟢 0.94× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / Enzyme forward 8.06 μs 7.62 μs 🟢 0.95× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / Enzyme forward 8.22 μs 7.79 μs 🟢 0.95× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / Mooncake forward 5.62 μs 5.91 μs 🔴 1.05× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / ReverseDiff (tape) 14.44 μs 13.7 μs 🟢 0.95× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / ForwardDiff 610.0 ns 640.0 ns ⚪ 1.05× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / ReverseDiff (tape) 16.0 μs 15.25 μs ⚪ 0.95× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / ReverseDiff (tape) 31.35 μs 29.89 μs ⚪ 0.95× ⚪ 1.0×
AD gradients / Thinned convolved series sum / Enzyme forward 10.41 μs 9.94 μs ⚪ 0.95× ⚪ 1.0×
AD gradients / Weighted Affine LogNormal nested logpdf / ForwardDiff 641.0 ns 670.0 ns ⚪ 1.05× ⚪ 1.0×
AD gradients / Thinned convolved series sum / Mooncake forward 11.68 μs 12.19 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / Mooncake forward 5.17 μs 5.39 μs ⚪ 1.04× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / Enzyme reverse 1.22 μs 1.17 μs ⚪ 0.96× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / ForwardDiff 480.0 ns 500.0 ns ⚪ 1.04× ⚪ 1.0×
AD gradients / Thinned convolved series sum / ReverseDiff (tape) 35.47 μs 34.06 μs ⚪ 0.96× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / ForwardDiff 481.0 ns 500.0 ns ⚪ 1.04× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / ForwardDiff 510.0 ns 530.0 ns ⚪ 1.04× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / Enzyme reverse 1.22 μs 1.18 μs ⚪ 0.97× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / Mooncake forward 5.18 μs 5.35 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / ForwardDiff 82.67 μs 85.26 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / Enzyme reverse 2.96 μs 2.86 μs ⚪ 0.97× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / Enzyme reverse 4.67 μs 4.81 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Thinned convolved series sum / Enzyme reverse 13.91 μs 14.3 μs ⚪ 1.03× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / Enzyme reverse 741.0 ns 721.0 ns ⚪ 0.97× ⚪ 1.0×
AD gradients / Weighted Affine LogNormal nested logpdf / Enzyme reverse 811.0 ns 791.0 ns ⚪ 0.98× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / Mooncake reverse 24.42 μs 23.87 μs ⚪ 0.98× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / ReverseDiff (tape) 26.0 μs 25.51 μs ⚪ 0.98× ⚪ 1.0×
AD gradients / Thinned convolved series sum / ForwardDiff 3.24 μs 3.29 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / Enzyme reverse 3.5 μs 3.44 μs ⚪ 0.98× ⚪ 1.0×
AD gradients / Thinned convolved series sum / Mooncake reverse 26.45 μs 26.0 μs ⚪ 0.98× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / Mooncake forward 392.54 μs 399.22 μs ⚪ 1.02× ⚪ 1.0×
AD gradients / Weighted LogNormal scalar logpdf / Mooncake reverse 19.31 μs 19.05 μs ⚪ 0.99× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / Mooncake reverse 15.6 μs 15.79 μs ⚪ 1.01× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / Enzyme reverse 1.94 μs 1.92 μs ⚪ 0.99× ⚪ 1.0×
AD gradients / Weighted Sequential observed-total logpdf / Enzyme forward 112.33 μs 111.42 μs ⚪ 0.99× ⚪ 1.0×
AD gradients / Transformed cumulative LogNormal logpdf / Mooncake reverse 15.66 μs 15.78 μs ⚪ 1.01× ⚪ 1.0×
AD gradients / Modified LogNormal log-link logpdf / Mooncake reverse 27.8 μs 27.65 μs ⚪ 0.99× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / Mooncake reverse 29.44 μs 29.5 μs ⚪ 1.0× ⚪ 1.0×
AD gradients / Affine LogNormal scale+shift logpdf / Mooncake reverse 16.41 μs 16.39 μs ⚪ 1.0× ⚪ 1.0×
AD gradients / Transformed thin LogNormal logpdf / Enzyme reverse 1.93 μs 1.93 μs ⚪ 1.0× ⚪ 1.0×
AD gradients / Weighted LogNormal observation-time weight logpdf / Mooncake reverse 17.67 μs 17.67 μs ⚪ 1.0× ⚪ 1.0×
AD gradients / Weighted Affine LogNormal nested logpdf / Mooncake reverse 17.25 μs 17.26 μs ⚪ 1.0× ⚪ 1.0×
AD gradients / Modified LogNormal identity-link logpdf / ForwardDiff 991.0 ns 991.0 ns ⚪ 1.0× ⚪ 1.0×
AD gradients / Product{Weighted} LogNormal vector logpdf / ForwardDiff 581.0 ns 581.0 ns ⚪ 1.0× ⚪ 1.0×

@seabbs-bot

Copy link
Copy Markdown
Collaborator Author

Triage: blocked on a maintainer ruling — please do not merge yet. Nothing here needs changing; the open question is where the verb lives.

The pending ruling

EpiAware/ComposedDistributions.jl#252 asks for "one defective type that composes everywhere", and states that "the single sub-stochastic type should be shared across all producers rather than reinvented at each site". Two things are still undecided:

  1. What a defective (sub-stochastic) law is — this PR answers it implicitly as "an ordinary wrapper whose total_mass is below one", with no distinct type. Piecewise-constant hazard multipliers with closed forms, including gating windows #105 asks instead for a gated leaf to "emit the same sub-stochastic marginal type the algebra defines for defective laws rather than a private flat-survival object".
  2. Which package owns the total_mass/is_defective accessor.

Why hosting here is a real problem, from the dependency graph

Read off the Project.toml files on main:

EpiAwareADTools        deps: Distributions
ConvolvedDistributions deps: Distributions, EpiAwareADTools
ComposedDistributions  deps: ConvolvedDistributions, Distributions, EpiAwareADTools
ModifiedDistributions  deps: Distributions
                       weakdeps: ComposedDistributions, ConvolvedDistributions,
                                 EpiAwareADTools

ComposedDistributions does not depend on ModifiedDistributions, in either direction — the only edge between them is ModifiedDistributions' weak dependency, which powers this repo's own extension. So a generic defined and exported here is unreachable from ComposedDistributions: CD cannot write using ModifiedDistributions to add total_mass(::Compete).

Three consequences of merging as-is:

  • CD#254 (no-event one_of marginal), CD#256 (chain collapse) and CD#260 (racing a defective cause) all need to report a defect. None of them can extend this generic, so each would define its own — the reinvention CD#252 exists to prevent.
  • export total_mass, is_defective claims two unqualified names. Neither name is currently defined anywhere in ComposedDistributions, ConvolvedDistributions, LoweredDistributions or EpiAwareADTools (checked), so nothing is broken today. But once CD defines its own, using ModifiedDistributions, ComposedDistributions becomes an export clash that forces callers to qualify both.
  • The only common ancestor of ModifiedDistributions, ComposedDistributions, ConvolvedDistributions and LoweredDistributions is EpiAwareADTools, which is why the earlier design review pointed there. Against that: EpiAwareADTools today hosts only AD-safety and primal helpers (cdf_ad_safe, primal, …), so a probability-semantics verb is off-theme for its name, and ModifiedDistributions has it as a weakdep rather than a hard dep — hosting there means adding a hard dependency. Hence a genuine call for @seabbs, between:
    (a) EpiAwareADTools (only shared ancestor, name no longer fits),
    (b) a new small shared-interface package,
    (c) accept ModifiedDistributions-local hosting and have CD define its own.

What was verified

The implementation itself checks out on the points not touched by the ruling. Two small items already raised in the review thread still stand against the current diff:

  • is_defective's docstring says "true when total_mass(d) < 1", but the code is total_mass(d) < 1 - _DEFECTIVE_TOL with _DEFECTIVE_TOL = 1e-9. Worth stating the tolerance.
  • _defective_total_mass's doubling search reads cdf(d, x), which for the clamped identity and quadrature paths only resolves hazard crossings up to _scan_upper(dist). The "exact" framing in the docstring and NEWS assumes a single crossing beyond that window.

Both are one-line documentation fixes and are not the blocker.

Related

#110 stacks on this branch and extends this generic with total_mass(d::_PiecewiseModified), stating gate's whole contract in terms of these accessors. It inherits this ruling. #109 is independent of it and can proceed on its own.

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

@seabbs

seabbs commented Jul 31, 2026

Copy link
Copy Markdown
Member

It looks like this needs to be implemented in composeddistributions and then extended here? Or both packages need to depend on a simple package that has simple distribution based concepts in it?

…tive-107

# Conflicts:
#	NEWS.md
#	src/ModifiedDistributions.jl
_defective_total_mass looped, doubling the query point past a deep base
quantile until successive cdfs agreed to _DEFECTIVE_TOL. In practice a
single doubling is always enough: the deficit at quantile(dist, 1 -
1e-12) is already below Float64's resolution near one, so a further
doubling can never move the returned cdf by more than the tolerance.
The loop's continuation/exhaustion branches were consequently
unreachable dead code, which is what left codecov/patch failing on
#108 regardless of what was tested. Dropped the loop; behaviour is
unchanged (full suite still passes, including the existing defective
and truncated-support cases).

Also added a test exercising total_mass/is_defective through the
numeric cumulative-hazard path (_NumericModified, a callable effect),
which the original PR only exercised implicitly - the closed-form
identity-link and discrete-per-bin paths were tested directly, the
numeric path was not.
@seabbs
seabbs merged commit 0ef6398 into main Aug 5, 2026
25 of 26 checks passed
seabbs-bot added a commit that referenced this pull request Aug 6, 2026
piecewise_effect(; breaks, multipliers) is a piecewise-constant
proportional-hazards multiplier for modify under the log link, with a
closed-form cumulative hazard (a sum of base log-survival differences
over segments) rather than the numeric quadrature path a callable
effect(t) always takes. Segment i applies from breaks[i-1] up to (but
not including) breaks[i], matching the right-continuous step-function
convention the issue's own illustrative example uses.

gate(dist, opens, closes) is a finite-window special case (multipliers
0/1/0 at the opens/closes breakpoints): the hazard is off before opens,
runs unmodified inside the window, and switches off again at closes,
leaving a deliberately sub-stochastic law whose residual "never
occurred" mass reads through total_mass/is_defective (#107) like any
other defective wrapper.

Stacked on the not-yet-merged #107 branch (PR #108) since gate reuses
its total_mass/is_defective accessor directly; total_mass gets one
additional dispatch for the piecewise path (exact when the trailing
multiplier is zero, the general numeric search otherwise).

Closes #105.

Co-authored-by: Sam Abbott <contact@samabbott.co.uk>
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.

Queryable sub-stochastic laws: total_mass and is_defective

2 participants