Skip to content

Keep the interval endpoints, gate width and power, and derive the last hand-picked tolerance - #3

Merged
soodoku merged 4 commits into
mainfrom
feat/interval-width-and-power-gates
Aug 9, 2026
Merged

Keep the interval endpoints, gate width and power, and derive the last hand-picked tolerance#3
soodoku merged 4 commits into
mainfrom
feat/interval-width-and-power-gates

Conversation

@soodoku

@soodoku soodoku commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Coverage cannot see a vacuous interval, the package had no gate for the power it
advertises, and its own tolerance=0.15 was the one number here chosen by hand.
This closes all three, and prepares 0.1.0.

1. Interval endpoints, widths, and a vacuity gate

monte_carlo computed the endpoints, used them once to build covered, and
dropped them. They are now kept: MonteCarloResult gained lowers, uppers,
widths, mean_width and median_width. Asking for a width on a study that
recorded no endpoints raises and says why, exactly as coverage already did.

assert_intervals_informative(result, nominal, label) fails a vacuous interval.

How the threshold is derived. The reference width is a measurement, not a
choice: for an estimator with spread sigma, the shortest interval covering at
1 - alpha has width 2 * z * sigma, and the study measures sigma as
sampling_sd. width_ratio(result, nominal) reports the observed mean width in
units of it, so no absolute width appears anywhere. vacuous_width_ratio(nominal, reps) is the width multiple at which a study of that size stops being able to
observe a miss: widening a calibrated interval by r drops its miss rate to
2(1 - Phi(r z)), and the threshold is the r at which the whole study expects
fewer than alpha misses. It comes out at 1.78 at 100 replicates, 1.96 at 400,
2.15 at 2000 — rising with reps, which inverts the usual direction and is
meant to: more replicates resolve rarer failures.

What is not derived, stated plainly in the docstring rather than hidden. How
many expected misses per study counts as "could not have failed" is a
convention. No sampling distribution fixes it, because correct procedures occupy
the whole range of widths above the oracle — a t interval at n=5 is 1.33 times
it, an anytime-valid interval more. So the gate does not fail on width alone. It
fails on width and a study that never once saw the interval miss (fewer than
sigmas misses; rule of three). A procedure that fails at its nominal rate is
not vacuous however wide it is, and no threshold on width can know that.

assert_narrower(narrow, wide) is the efficiency comparison sortition wants,
banded by the Monte Carlo standard error of the difference in mean width.

2. Power

assert_power(result, minimum) — one-sided, floor from binomial_band around
the claim. Power is a floor, and assert_proportion's two-sided band fails a
test for being better than promised.

assert_more_powerful(more, less) — bands the gap between two rejection rates.
It replaces a.rejection_rate > b.rejection_rate, which passes on a gap of one
replicate in four hundred.

3. The hard-coded 0.15 — derived, and the default changed

The default changed from 0.15 to a value derived from reps. Flagging
this explicitly, as asked, because it changes behaviour for consumers.

se_ratio divides a mean of reps reported standard errors by a sample standard
deviation of reps estimates. Both are noisy at a rate the study knows:

  • numerator: cv / sqrt(reps), cv the coefficient of variation of the reported
    standard errors — zero for an estimator that reports the same one every time;
  • denominator: sqrt((kappa - 1) / (4 * reps)), kappa the estimator's
    kurtosis, measured from the study and floored at 3.

In quadrature at three sigma: 0.21 at 100 replicates, 0.11 at 400, 0.05 at
2000
for a normal estimator. The fixed 0.15 was wrong in both directions at
once — loose enough to certify a 12% error in a 2000-replicate study, tight
enough to fail correct estimators in a shallow one. tolerance= still overrides
it; se_ratio_tolerance(result) returns the band.

Blast radius, measured. Of the six consuming repos only geoinference uses
the default (test_inference.py:410); the rest pass tolerance= or do not call
the gate. Its suite was run against this branch unmodified — PYTHONPATH pointed
at this checkout, no consumer file touched — and reports 43 passed, 4 subtests
passed
.

Verification the ticket asked for

The gate reproduces all three real-world cases, in
test_the_three_workarounds_in_the_wild_are_caught:

Case Constructed as Gate
geoinference test_inference.py:311 — "so wide it covers every single time" ±8 sd, 300 reps, coverage 1.0 fails
alsgls test_econometrics.py:18 — "covering 100% of the time" ±6 sd, 300 reps, coverage 1.0 fails
incline test_bayesian_calibration.py:133 — "ratio to 3e7 while coverage stayed high" half-width 3e7 × 1.96 sd fails

And it stays silent on the case that separates conservatism from vacuity: a real
t interval at n=5, 1.33 times the oracle width, coverage 0.95 — passes, because
the study watches it miss.

Negative tests, watched failing

Every new gate has one, and each was watched fail with its check removed (a
pytest plugin that no-ops the gate) and pass with it restored. Failures observed
per neutered check:

  • assert_intervals_informative → 4 failed (incl. all three real cases)
  • assert_narrower → 3 failed
  • assert_power → 4 failed
  • assert_more_powerful → 3 failed
  • MonteCarloResult.__post_init__ → 4 failed
  • derived SE tolerance pinned back to 0.15 → 1 failed
    (test_the_same_se_error_passes_a_shallow_study_and_fails_a_deep_one)

With the checks in place: 82 passed.

The -O subprocess test now covers all nine gates, so none of them can be turned
into a no-op by python -O.

Codex review (release gate 3) — three P1s, all fixed

codex exec review --base main found three real defects. Each was reproduced
first, then fixed, then watched failing again with the fix reverted:

  1. A width comparison could be won on one replicate. _mean_gap_se returned
    zero for a study with fewer than two replicates, and zero uncertainty reads as
    certainty. Reproduced: assert_narrower passed on one replicate each. Now a
    ValueError.
  2. The plug-in Wald standard error is exactly zero at a rate of 0 or 1.
    Reproduced: one replicate rejecting vs one not rejecting passed a three-sigma
    claim from two observations. Now Agresti-Caffo — that case is 2.6 sigma and
    fails, while 400/400 against 0/400 still passes.
  3. The derived SE tolerance assumed normality. Var(s)/sigma^2 is
    (kappa-1)/(4n), not 1/(2n). Reproduced: a calibrated estimator with
    t(5) sampling error was flagged in 19 of 200 studies, against the 0.3%
    three sigma advertises. Now 2 of 200.

Release preparation (0.1.0)

  • CHANGELOG.md [Unreleased]## [0.1.0] - 2026-08-08.
  • Tag convention: no tags exist, and release.yml triggers on tags: ["v*"], so
    it must be v0.1.0.
  • README.md's pip install simcheck is not yet true — a git-install line is
    added alongside it until the first PyPI release lands.
  • Do not merge this PR yourself and do not tag from this branch. On a
    v0.1.0 tag push the build and GitHub-release jobs succeed and the publish
    job fails, because no PyPI trusted publisher exists for finite-sample/simcheck
    yet. That failure is accepted. It is recoverable without a version bump: a
    failed publish uploads nothing, and workflow_dispatch against the tag with
    publish: true re-builds the same version and completes the upload once the
    publisher is registered.

Checks

uv run ruff check . clean · ruff format --check . 13 files formatted ·
pyright 0 errors · pydoclint src no violations · pytest 82 passed ·
python -O -m pytest 82 passed · doctests 4 passed.

No consuming repo was modified.

soodoku and others added 2 commits August 8, 2026 23:37
Coverage cannot see a vacuous interval. `assert_coverage` is satisfied by one so
wide it always covers, because a rate of 1.0 sits inside the binomial band at any
study smaller than about sixty replicates, and `coverage > 0.9` written by hand
is satisfied by it always. Three consuming repositories hit this independently
and each left a comment where a test should have been; one of them after an
inflation heuristic drove a reported standard error to 3e7 times the estimation
error while coverage stayed high. All three are reproduced as tests here.

The runner computed the endpoints, used them once and dropped them, so the
evidence was being thrown away. It now keeps them, and `MonteCarloResult` gained
widths.

`assert_intervals_informative` fails only when the width exceeds what the study
can still see fail *and* the study never saw it fail. The conjunction is the
point: a t interval at n=5 is 1.33 times the oracle width and correct, so width
alone cannot separate conservatism from vacuity. The reference width is measured
by the study rather than chosen; what remains a convention is written down in
`vacuous_width_ratio`'s docstring rather than presented as a derivation.

`assert_power` and `assert_more_powerful` fill the hole under the package's own
claim to answer whether a test has power. Power is a floor, so the first is
one-sided; the second replaces `a.rejection_rate > b.rejection_rate`, which is
satisfied by a gap of one replicate in four hundred.

`assert_se_calibrated`'s tolerance was 0.15, the one number here chosen by hand.
The sampling distribution of the ratio gives it: three sigma is 0.21 at 100
replicates and 0.05 at 2000, so the fixed value was tight enough to fail correct
estimators in a fast tier and loose enough to certify a 12% error in a deep one.
It is now derived by default. geoinference is the only consumer relying on the
default and its suite still passes: 43 passed, 4 subtests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three were reproduced before being fixed and watched failing after the fix
was reverted.

**A width comparison could be won on one replicate.** `_mean_gap_se` returned
zero when either study had fewer than two replicates, and a zero standard error
reads as certainty, so `assert_narrower` certified whichever method the single
draw happened to favour. It now refuses the comparison.

**The plug-in Wald standard error is exactly zero at a rejection rate of 0 or
1.** One replicate rejecting against one not rejecting was a gap of 1.0 with no
uncertainty, so `assert_more_powerful` certified a three-sigma difference from
two observations. Agresti-Caffo gives that case 2.6 sigma and fails it, while
total separation over four hundred replicates is still hundreds of sigma.

**The derived SE tolerance assumed normality.** The relative noise in a sample
standard deviation is `sqrt((kappa-1)/(4n))`, not `1/sqrt(2n)`, so a calibrated
estimator with Student t(5) sampling error was flagged in 19 of 200 studies
against the 0.3% three sigma advertises -- the "gate that fires on correct code"
failure this package exists to prevent. The kurtosis is now estimated from the
study and floored at 3, since the sample kurtosis is downward-biased at Monte
Carlo replicate counts and must not be allowed to narrow the band. 2 of 200 now.

geoinference, the only consumer relying on the default tolerance, still passes:
43 passed, 4 subtests passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7743eb4682

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/simcheck/gates.py Outdated
"reject/accept decisions, so the two cannot be compared"
)
strong, weak = more.rejection_rate, less.rejection_rate
gap = strong - weak

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use adjusted rates for the Agresti-Caffo gap

When the studies have unequal replicate counts near a boundary, this combines the raw rate gap with an adjusted variance, which can certify an unresolved difference. For example, 1/1 rejections versus 0/100 gives a raw gap of 1 and passes this three-sigma gate at about 3.67 SE, whereas applying the stated add-one-success/add-one-failure adjustment to both rates gives a gap of about 0.657, only 2.41 SE. Compute the gap from the adjusted rates as well as using their adjusted variance.

Useful? React with 👍 / 👎.

Comment thread src/simcheck/gates.py
Comment on lines +542 to +546
reported = np.asarray(result.standard_errors, dtype=float)
mean_reported = float(np.mean(reported))
variation = (
float(np.std(reported, ddof=1)) / mean_reported if mean_reported else 0.0
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject absent standard errors in the tolerance helper

When a study uses Estimate's default missing standard error, mean_reported and variation become NaN and this public helper returns NaN rather than raising. The README exposes this helper for callers implementing their own checks, and a check written in the same form as the gate (if deviation > tolerance: raise) then silently passes because comparisons with NaN are false. Validate that the reported standard errors produce a finite usable mean before deriving the tolerance.

Useful? React with 👍 / 👎.

Both reproduced before the fix and watched failing after it was reverted.

**Agresti-Caffo was applied to the variance but not to the gap**, which is not
Agresti-Caffo and leaves the hole open whenever the two studies are different
sizes: one replicate rejecting against a hundred not rejecting is a raw gap of
1.0, and it cleared three sigma against an adjusted standard error. Both halves
now come from the adjusted rates, which puts that case at 2.4 sigma and the
one-against-one case at 0.9, while total separation over four hundred replicates
is unchanged at hundreds of sigma.

**`se_ratio_tolerance` returned NaN for a study that reported no standard
error.** The helper is public and the README points callers at it, so the obvious
hand-written check -- `if abs(ratio - 1) > tolerance: raise` -- compared against
NaN, got False, and passed silently on a study that measured nothing. It raises
now. The gate itself was never affected: it rejects a non-finite ratio first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@soodoku

soodoku commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Update: two further P1/P2 defects from the hosted Codex review, both fixed

Fetched from both endpoints (reviews and pulls/3/comments) as the release checklist requires. Each was reproduced first, fixed, then watched failing again with the fix reverted.

[P1] Agresti-Caffo was applied to the variance but not to the gap (gates.py:872). That is not the Agresti-Caffo interval, and it leaves the same hole open when the two studies are different sizes. Reproduced: assert_more_powerful(1 of 1, 0 of 100) passed at 3.67 SE on a raw gap of 1.0. Both halves now come from the adjusted rates — that case is 2.41 SE and fails, the one-against-one case is 0.87 SE and fails, and 400/400 against 0/400 is unchanged at hundreds of sigma and passes. Test: test_one_replicate_cannot_beat_a_hundred.

[P2] se_ratio_tolerance returned NaN for a study with no standard errors (gates.py:546). The helper is public and the README points callers at it, so the obvious hand-written check if abs(ratio - 1) > tolerance: raise compares against NaN, gets False, and passes silently on a study that measured nothing — the exact class of defect this package exists to prevent. It raises now. assert_se_calibrated itself was never affected: it rejects a non-finite ratio before reaching the tolerance. Test: test_the_se_tolerance_refuses_a_study_with_no_standard_errors.

Both fixes watched failing with the check reverted, passing with it in place.

After: 84 passed (was 82), python -O 84 passed, doctests 4 passed, ruff clean, pyright 0 errors, pydoclint no violations. geoinference re-run unmodified against this branch: 43 passed, 4 subtests passed.

The changelog entry was written before midnight rolled over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@soodoku
soodoku merged commit 5ce62f8 into main Aug 9, 2026
11 checks passed
@soodoku
soodoku deleted the feat/interval-width-and-power-gates branch August 9, 2026 07:21
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