Skip to content

Refactor/projection and validation gaps - #72

Merged
correspondMerchant merged 3 commits into
mainfrom
refactor/projection-and-validation-gaps
Aug 5, 2026
Merged

Refactor/projection and validation gaps#72
correspondMerchant merged 3 commits into
mainfrom
refactor/projection-and-validation-gaps

Conversation

@correspondMerchant

Copy link
Copy Markdown
Owner

1. Lift the restart selection out of the solver

The numerical optimizer's choice among its restarts — filter to the ones inside
the bounds, keep all of them if none is, take the cheapest, project it onto the
bounds, recompute its cost there — was inline inside a function nested in
optimize_cost_nlcoptim(). The last two steps only run when every restart has
left the box, which no two-component problem does, so #71 had to reach them
through a three-component fixture whose eleven restarts each miss the bounds by
about a unit in the last place. A solver converging more tightly would have
stopped the fallback firing and those tests would have kept passing while
covering nothing, and that could not be detected from outside: the projection
leaves a component exactly on a bound, and so does a solver that converged there.

It is now select_restart_within_bounds(), callable with a hand-built restart
matrix and no solver, model or data. The body is unchanged. Unit tests cover all
restarts in the box, some, none, a cost tie, a failed restart among good ones,
every restart failed, a single restart, and a winner genuinely outside the
bounds. Removing the projection now fails 13 of them, under the exact tighter
solver tolerances that previously disarmed the fixture
, with the fixture
contributing zero failures. The fixture is kept for the integration path.

2. rec_int_processor() was defeating a guard at its own call site

It passed character(0) as the set of coefficient names already claimed by other
terms, so the anchored-name fallback excluded nothing and the guard against a
covariate being read as a fixed effect could not fire. It now builds the list the
way get_confidence_set() does, which meant threading additional_covariates
through from lago_optimization().

3. Center weights were validated and then used unnormalised

Weights within a thousandth of summing to 1 were accepted as-is. They multiply
the per-centre outcomes and are summed, so weights summing to 0.999 scaled every
reported outcome by 0.999 — including the outcome the recommendation is chosen
against. They are renormalised where they are validated. The tolerance is
unchanged, since it states the input was meant to be a set of weights, and the
correction is silent because warning would fire on rounded input the
documentation invites.

The numerical optimizer's choice among its restarts was written inline inside a
function nested in optimize_cost_nlcoptim(). Two of its steps, projecting the
chosen restart onto the intervention bounds and recomputing its cost there, only
run when every restart has left the box, which no two-component problem does. The
regression suite had to reach them through a three-component configuration whose
eleven restarts each miss the bounds by a unit or so in the last place, so a
solver converging more tightly would have stopped the fallback firing and those
tests would have kept passing while covering nothing. That could not be guarded
from the outside: the projection leaves a component exactly on a bound and so
does a solver that converged there, so the returned value cannot distinguish
them.

The filter, the fallback, the choice, the projection and the cost recomputation
are one decision, so they move together into select_restart_within_bounds(),
which takes the restart results and the bounds and needs no solver, no model and
no data. The body is unchanged. Its unit tests cover all restarts in the box,
some, none, a tie in cost, a failed restart among good ones, every restart
failed, a single restart, and a winner genuinely outside the bounds. Removing
the projection now fails thirteen of them, where before it was caught only by the
fragile fixture, which is kept for the integration path.

Two smaller gaps found while reviewing the same code:

rec_int_processor() passed character(0) as the set of coefficient names already
claimed by other terms, so at that call site the anchored-name fallback excluded
nothing and the guard against a covariate being read as a fixed effect could not
fire. It now builds the list the same way get_confidence_set() does, which meant
threading additional_covariates through from lago_optimization(). The fallback
turns out not to be reachable from any model the package fits, since the term
mapping is only unavailable for an intercept-only fit, but the argument was
wrong and a mutation to it survived the whole suite.

Center weights within a thousandth of summing to 1 were accepted and then used
as they were. They multiply the per-center outcomes and are summed, so weights
summing to 0.999 scaled every reported outcome by 0.999, including the outcome
the recommendation is chosen against. They are now renormalised where they are
validated. The tolerance is unchanged, since it is documented and states that the
input was meant to be a set of weights; renormalising is exact for a caller whose
weights already sum to 1, and silent, because warning would fire on rounded input
the documentation invites.

The suite goes from 629 expectations to 712.
The new internal's documentation said the projection can only raise the cost of
a monotone cost function, so that reporting the solver's cost understates what
the recommendation costs. That is wrong in both halves. Projection moves a
component up to a lower bound or down to an upper one, so on an increasing cost
function it can move the cost either way, and a test in the same file
demonstrates it: c(0.25, 7) projects to c(1, 5) and costs 27 against the 35.5
the solver reported. The cost function need not be monotone at all, since
create_cost_function() builds whatever polynomial the caller's coefficients
describe.

The claim appeared in the roxygen, in the generated manual page and in two test
comments. All four now say the recomputed cost can be on either side of the
solver's, which is the reason it is recomputed rather than adjusted.

Two smaller corrections in the same files. The note on the three-component
fixture said two restarts return an exactly-zero bound violation under tighter
solver tolerances; the count depends on how the objective is built, so it now
says several, which is what the argument needs. And an assertion on a recorded
outcome value was described as verified by hand below, with no derivation
following: it is a fixture recorded from the fixed tree, and says so.
…claims

Review found that deleting the single line in lago_optimization() that forwards
additional_covariates to rec_int_processor() left the whole suite green. The
existing test drives rec_int_processor() directly, so it pins what the callee
does with the argument and never runs the caller, which is the same structural
gap one level up from the one this branch set out to close. The fallback the
argument protects needs a model whose term mapping is missing, and
lago_optimization() never fits one, so it cannot be reached by running an
optimization: the test instead replaces rec_int_processor() for one call and
asserts what it received. Deleting the forwarding line now fails it.

Two claims in comments were wrong. The note on renormalising center weights said
it is exactly a no-op for a compliant caller, which does not hold on the default
weights: a vector of center sizes divided by its own total need not sum to
exactly 1, and about one such vector in four hundred is a unit in the last place
away, so those runs move by that much. It now says so, and says the movement is
the correction working rather than an error introduced.

The note in rec_int_processor() said the term mapping is always available for
models this package fits, making the fallback unreachable. A model fitted with
model = FALSE whose data has left scope cannot rebuild its mapping, and the
exported get_confidence_set() takes whatever model the caller passes, so the
fallback is reachable and the guard on it is not decoration.
@correspondMerchant
correspondMerchant merged commit 4c2e50c into main Aug 5, 2026
@correspondMerchant
correspondMerchant deleted the refactor/projection-and-validation-gaps branch August 5, 2026 19:02
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