Skip to content

Fix/exported guards and identity ci - #74

Merged
correspondMerchant merged 4 commits into
mainfrom
fix/exported-guards-and-identity-ci
Aug 7, 2026
Merged

Fix/exported guards and identity ci#74
correspondMerchant merged 4 commits into
mainfrom
fix/exported-guards-and-identity-ci

Conversation

@correspondMerchant

Copy link
Copy Markdown
Owner

A binary outcome's interval was always built on the logit scale

The branch keyed on outcome_type and applied expit() and the logit
delta-method factor unconditionally, ignoring link. A binomial model fitted
with link = "identity" got an interval on the wrong scale: 0.6810.685
where the interval on the model's own scale is 0.7590.779, which does not
contain the point estimate.

This turned out bigger than the interval. Membership in the confidence set is
decided by whether an interval brackets the goal, so the wrong scale discarded
the set: on one fixture the released version reports an empty set where the
answer is 6 of 12 grid points, and on another a disjoint set of 10 wrong
interventions. The point estimate was always right.

The logit path is unchanged term for term — reassociating se * p * (1 - p)
moves the result at ~2e-16, so the order is preserved deliberately.

The exported entry point bypassed weight validation

get_confidence_set() is exported and does not go through validate_inputs(),
so #73's weight guards did not protect it: a negative centre weight gave a
confidence bound above 1 for a binary outcome, silently. The checks are now one
function with two call sites, so the paths cannot diverge.

The all-failed message recommended a method that cannot help

It suggested grid search and blamed ">3 intervention components". The path is
reached through rank deficiency, where grid search fails on the same input for
the same reason — it now names the terms that could not be estimated. Grid
search gained the same guard rather than walking into its own unguarded
comparison.

…n link

Three defects the reviews of the previous change disclosed and left.

The message refusing an all-failed numerical optimization recommended grid
search and blamed having more than three intervention components. Neither is
right for the case that actually reaches it. A model whose terms are not all
estimable returns NA coefficients, every outcome computed from them is NA, and
every restart fails, so the path is reached through rank deficiency rather than
through a hard optimization, and grid search fails on the same input for the
same reason. The refusal now names the terms that could not be estimated and
says to drop or combine them, keeping the old wording only for the case it was
written for. Grid search no longer walks into its own unguarded comparison on
the same input: it refuses it with the same message, which fires exactly when the
comparison it precedes would have failed.

get_confidence_set() is exported and does not go through validate_inputs(), so
the weight checks added last time did not protect it. A negative center weight
produced an upper confidence bound above 1 for a binary outcome, silently. The
checks are now one function that both entry points call, so the two cannot
disagree, and the argument documentation that already claimed weights must be
non-negative and finite is true for both. The sum-to-one check stays out of the
exported path deliberately: validate_inputs() renormalises, and the confidence
set has nothing to renormalise into.

A binary outcome's interval was built on the logit scale whatever link the model
was fitted on, because that branch keyed on the outcome type and applied expit()
and the logit delta-method factor unconditionally. A binomial model fitted with
an identity link therefore reported an interval on the wrong scale: for one such
fit 0.681 to 0.685, where the interval on the model's own scale is 0.759 to
0.779, which does not even contain the estimate. The point estimate was always
right; only the interval was wrong. The branch now follows the link, and the
logit arithmetic is unchanged term for term, since reassociating it moves the
result in the last few digits.
Two points from review, both about a guard saying something a caller can act
on.

is.finite() is TRUE for every level of a factor, so a factor whose levels look
like numbers passed the finiteness check, and the comparison after it then gave
NA and raised the base-R "missing value where TRUE/FALSE needed" that the guard
exists to replace. A non-numeric weight vector is now refused for what it is,
with the same treatment for a character vector. This was inherited from the
change that introduced the guard rather than new here.

The rank-deficient refusal told the caller to drop or combine the collinear
predictors. That is unfollowable on the route this branch newly names, where
center-level data with one row per center is saturated rather than collinear:
dropping a predictor leaves nothing to estimate the others from either. The
message now covers that case and says what does help, which is more rows per
center or fitting without the center effects.
The branch that reports a rank-deficient fit is entered when at least one
coefficient could not be estimated, and the tests only ever passed it two. A
condition of more than one aliased term therefore satisfied them while restoring
the whole defect for a single aliased coefficient: grid search recommended, and
more than three intervention components blamed, on a fit with two. One aliased
term is not a corner case, it is what the message's own example produces, since
two intervention components that are rescalings of one another alias exactly
one. Both guards are pinned at that boundary now.

Verified by mutating the condition in a throwaway tree and running the checks
there: the assertion fails, naming the wrong message it received.
Every assertion on the rank-deficient message matched a substring, so a message
listing every coefficient in the model satisfied all of them. That message tells
the caller to drop the intercept, both intervention components and every center,
which is advice they can follow and act on, and worse than naming nothing. The
terms glm() did estimate are now asserted absent.

The assertions are at the call site that decides which names are passed on,
because that is the only place the difference shows: passing a hand-built list
straight to the guard cannot see a caller that builds the wrong list. Verified by
making that caller name every coefficient in a throwaway tree, where the checks
then fail.
@correspondMerchant
correspondMerchant merged commit 4082c73 into main Aug 7, 2026
1 check passed
@correspondMerchant
correspondMerchant deleted the fix/exported-guards-and-identity-ci branch August 7, 2026 14:36
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